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,125 @@
1
+ import { join } from "node:path";
2
+ import { EXIT } from "./types.js";
3
+ import { Output } from "./cli/output.js";
4
+ import { CliError, authError, scopeError } from "./cli/errors.js";
5
+ import { ConfigStore, configDir, profileFor, readRepoConfig, resolveEndpoint } from "./config.js";
6
+ import { McpClient } from "./mcp/client.js";
7
+ import { ToolCatalog, tokenFingerprint } from "./mcp/catalog.js";
8
+ import { createCredentialStore, ResilientStore } from "./auth/store.js";
9
+ import { TokenProvider } from "./auth/token.js";
10
+ export function createContext(deps) {
11
+ const env = deps.env ?? process.env;
12
+ const cwd = deps.cwd ?? process.cwd();
13
+ const out = new Output({ stdout: deps.stdout, stderr: deps.stderr, color: deps.flags.color, env, quiet: deps.flags.quiet, json: deps.flags.json });
14
+ const dir = configDir(env);
15
+ const config = new ConfigStore(dir);
16
+ const endpoint = resolveEndpoint(deps.flags.url, env, config.read().endpoint);
17
+ const profile = profileFor(endpoint);
18
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
19
+ const now = deps.now ?? (() => new Date());
20
+ let store;
21
+ if (deps.store)
22
+ store = deps.store;
23
+ else {
24
+ const { primary, fallback } = createCredentialStore({ configDir: dir, env, platform: deps.platform });
25
+ store = new ResilientStore(primary, fallback, (t) => out.warn(t));
26
+ }
27
+ const tokens = new TokenProvider({ env, store, profile, endpoint, fetch: fetchImpl, now });
28
+ const debug = deps.flags.debug ? (line) => out.info(out.dim(`[debug] ${line}`)) : undefined;
29
+ let client = deps.client ?? null;
30
+ const getClient = () => {
31
+ if (client)
32
+ return client;
33
+ client = new McpClient({ endpoint, tokens, fetch: fetchImpl, clientName: "scribeclock-cli", clientVersion: deps.version, debug });
34
+ return client;
35
+ };
36
+ const catalog = new ToolCatalog({
37
+ cacheDir: join(dir, "cache"),
38
+ endpoint,
39
+ tokenFingerprint: null, // set lazily once the token is known (below)
40
+ listTools: async () => getClient().listTools(),
41
+ fetch: fetchImpl,
42
+ now,
43
+ warn: (t) => out.warn(t),
44
+ });
45
+ const ctx = {
46
+ flags: deps.flags,
47
+ out,
48
+ env,
49
+ cwd,
50
+ platform: deps.platform ?? process.platform,
51
+ now,
52
+ version: deps.version,
53
+ endpoint,
54
+ profile,
55
+ config,
56
+ repo: readRepoConfig(cwd),
57
+ store,
58
+ tokens,
59
+ catalog,
60
+ fetch: fetchImpl,
61
+ stdin: deps.stdin ?? { isTTY: Boolean(process.stdin.isTTY), stream: process.stdin },
62
+ registry: deps.registry,
63
+ client: getClient,
64
+ org: () => deps.flags.org || ctx.repo.config.org || config.read().default_org || undefined,
65
+ async tools() {
66
+ // The cache is per token (tools/list is scope-filtered), so make sure one is known first.
67
+ if (!deps.client) {
68
+ const token = await tokens.getToken();
69
+ if (!token)
70
+ throw authError();
71
+ catalog.setTokenFingerprint(tokenFingerprint(token));
72
+ }
73
+ return catalog.load();
74
+ },
75
+ async callRaw(tool, args = {}, opts = {}) {
76
+ const finalArgs = { ...args };
77
+ for (const k of Object.keys(finalArgs))
78
+ if (finalArgs[k] === undefined)
79
+ delete finalArgs[k];
80
+ if (!opts.skipOrg && finalArgs.organization_id === undefined) {
81
+ const org = ctx.org();
82
+ if (org) {
83
+ let takesOrg = true;
84
+ try {
85
+ const def = (await ctx.tools()).find((t) => t.name === tool);
86
+ takesOrg = !def || Boolean(def.inputSchema?.properties?.organization_id);
87
+ }
88
+ catch {
89
+ takesOrg = true;
90
+ }
91
+ if (takesOrg)
92
+ finalArgs.organization_id = org;
93
+ }
94
+ }
95
+ debug?.(`call ${tool} ${JSON.stringify(Object.keys(finalArgs))}`);
96
+ const outcome = await getClient().callTool(tool, finalArgs);
97
+ if (!outcome.ok && outcome.error === "insufficient_scope") {
98
+ const d = outcome.data;
99
+ throw scopeError(typeof d.required_scope === "string" ? d.required_scope : undefined, Array.isArray(d.granted_scopes) ? d.granted_scopes.map(String) : undefined);
100
+ }
101
+ if (!outcome.ok && outcome.error === "invalid_token")
102
+ throw authError("The token was rejected.", "Run `scribeclock login` again.");
103
+ return outcome;
104
+ },
105
+ async call(tool, args = {}, opts = {}) {
106
+ const outcome = await ctx.callRaw(tool, args, opts);
107
+ if (!outcome.ok)
108
+ throw toolError(tool, outcome);
109
+ return outcome.data;
110
+ },
111
+ };
112
+ return ctx;
113
+ }
114
+ const CONFLICT_ERRORS = new Set(["already_assigned", "conflict", "overlap", "already_clocked_in", "locked"]);
115
+ export function toolError(tool, outcome) {
116
+ const code = outcome.error ?? "error";
117
+ const message = outcome.message ?? "The request failed.";
118
+ const hint = typeof outcome.data.hint === "string" ? outcome.data.hint : undefined;
119
+ return new CliError(`${tool}: ${message}`, {
120
+ id: code,
121
+ code: CONFLICT_ERRORS.has(code) ? EXIT.CONFLICT : EXIT.ERROR,
122
+ hint,
123
+ details: outcome.data,
124
+ });
125
+ }
package/dist/git.js ADDED
@@ -0,0 +1,144 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ export function git(args, cwd) {
4
+ try {
5
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10000 }).trim();
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ export function repoRoot(cwd) {
12
+ return git(["rev-parse", "--show-toplevel"], cwd);
13
+ }
14
+ export function remoteUrl(cwd) {
15
+ return git(["remote", "get-url", "origin"], cwd);
16
+ }
17
+ /** What the server's repo tools expect: the remote URL, else the folder name. */
18
+ export function repoRef(cwd) {
19
+ const remote = remoteUrl(cwd);
20
+ if (remote)
21
+ return remote;
22
+ const root = repoRoot(cwd);
23
+ return root ? basename(root) : null;
24
+ }
25
+ /** owner/repo from a remote URL, when it looks like GitHub. */
26
+ export function githubSlug(remote) {
27
+ if (!remote)
28
+ return null;
29
+ const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(remote);
30
+ return m ? `${m[1]}/${m[2]}` : null;
31
+ }
32
+ export function currentBranch(cwd) {
33
+ const b = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
34
+ return b && b !== "HEAD" ? b : b;
35
+ }
36
+ export function lastCommit(cwd) {
37
+ const line = git(["log", "-1", "--format=%H%x1f%s"], cwd);
38
+ if (!line)
39
+ return null;
40
+ const [sha, message] = line.split("\x1f");
41
+ return { sha, message: message ?? "" };
42
+ }
43
+ /** The `git` object the coordination tools accept. */
44
+ export function gitState(cwd) {
45
+ const state = {};
46
+ const porcelain = git(["status", "--porcelain"], cwd);
47
+ if (porcelain !== null) {
48
+ const files = [];
49
+ const conflicts = [];
50
+ for (const line of porcelain.split("\n")) {
51
+ if (!line.trim())
52
+ continue;
53
+ const code = line.slice(0, 2);
54
+ const path = line.slice(3).trim().replace(/^.* -> /, "");
55
+ if (/U|AA|DD/.test(code))
56
+ conflicts.push(path);
57
+ files.push(path);
58
+ }
59
+ state.uncommitted_files = files.slice(0, 200);
60
+ state.uncommitted_count = files.length;
61
+ if (conflicts.length)
62
+ state.conflicts = conflicts.slice(0, 100);
63
+ }
64
+ const counts = git(["rev-list", "--left-right", "--count", "@{u}...HEAD"], cwd);
65
+ if (counts) {
66
+ const [behind, ahead] = counts.split(/\s+/).map(Number);
67
+ state.behind = behind;
68
+ state.ahead = ahead;
69
+ state.unpushed_commits = ahead;
70
+ }
71
+ const lc = lastCommit(cwd);
72
+ if (lc)
73
+ state.last_commit = { sha: lc.sha.slice(0, 40), message: lc.message.slice(0, 300) };
74
+ return state;
75
+ }
76
+ /** Files touched by uncommitted changes plus the last commit (what a handoff / progress report lists). */
77
+ export function changedFiles(cwd, sinceRef) {
78
+ const set = new Set();
79
+ const dirty = git(["status", "--porcelain"], cwd);
80
+ for (const line of (dirty ?? "").split("\n"))
81
+ if (line.trim())
82
+ set.add(line.slice(3).trim().replace(/^.* -> /, ""));
83
+ const range = sinceRef ? `${sinceRef}..HEAD` : "HEAD~1..HEAD";
84
+ const committed = git(["diff", "--name-only", range], cwd);
85
+ for (const f of (committed ?? "").split("\n"))
86
+ if (f.trim())
87
+ set.add(f.trim());
88
+ return [...set].slice(0, 200);
89
+ }
90
+ export function commitsSince(cwd, sinceIso, opts = {}) {
91
+ const args = ["log", `--since=${sinceIso}`, "--format=%H%x1f%h%x1f%s%x1f%cI", `--max-count=${opts.max ?? 50}`];
92
+ if (opts.untilIso)
93
+ args.push(`--until=${opts.untilIso}`);
94
+ if (opts.author)
95
+ args.push(`--author=${opts.author}`);
96
+ const raw = git(args, cwd);
97
+ if (!raw)
98
+ return [];
99
+ return raw
100
+ .split("\n")
101
+ .filter(Boolean)
102
+ .map((line) => {
103
+ const [sha, short, message, date] = line.split("\x1f");
104
+ return { sha, short, message, date };
105
+ });
106
+ }
107
+ export function gitUserEmail(cwd) {
108
+ return git(["config", "user.email"], cwd);
109
+ }
110
+ export function hooksPath(cwd) {
111
+ return git(["config", "core.hooksPath"], cwd);
112
+ }
113
+ /** `#123` in a commit message or branch; bare numbers in branch segments (feat/123-thing, task-123, sc-123). */
114
+ export function taskKeyFromText(text) {
115
+ if (!text)
116
+ return null;
117
+ const hash = /(?:^|[^A-Za-z0-9&])#(\d{1,6})(?![0-9])/.exec(text);
118
+ if (hash)
119
+ return `#${hash[1]}`;
120
+ return null;
121
+ }
122
+ export function taskKeyFromBranch(branch) {
123
+ if (!branch)
124
+ return null;
125
+ const direct = taskKeyFromText(branch);
126
+ if (direct)
127
+ return direct;
128
+ const m = /(?:^|[/_-])(?:sc|task|t|issue)?-?(\d{1,6})(?=$|[/_-])/i.exec(branch);
129
+ return m ? `#${m[1]}` : null;
130
+ }
131
+ /** The task the current work belongs to: branch name first, then the last commit message. */
132
+ export function detectTaskKey(cwd) {
133
+ const branch = currentBranch(cwd);
134
+ const fromBranch = taskKeyFromBranch(branch && branch !== "main" && branch !== "master" ? branch : null);
135
+ if (fromBranch)
136
+ return { key: fromBranch, source: "branch" };
137
+ const fromCommit = taskKeyFromText(lastCommit(cwd)?.message);
138
+ if (fromCommit)
139
+ return { key: fromCommit, source: "commit" };
140
+ return null;
141
+ }
142
+ export function tagDate(cwd, ref) {
143
+ return git(["log", "-1", "--format=%cI", ref], cwd);
144
+ }
package/dist/index.js ADDED
@@ -0,0 +1,134 @@
1
+ import { createRequire } from "node:module";
2
+ import { parseGlobals } from "./cli/parser.js";
3
+ import { renderCommandHelp, renderGlobalHelp } from "./cli/registry.js";
4
+ import { CliError, isCliError, usageError } from "./cli/errors.js";
5
+ import { closest } from "./cli/parser.js";
6
+ import { createContext } from "./context.js";
7
+ import { buildRegistry } from "./commands/index.js";
8
+ import { runGeneratedToolCommand } from "./commands/tools.js";
9
+ import { updateNotice } from "./update.js";
10
+ import { EXIT } from "./types.js";
11
+ import { Output } from "./cli/output.js";
12
+ export const VERSION = (() => {
13
+ try {
14
+ return createRequire(import.meta.url)("../package.json").version;
15
+ }
16
+ catch {
17
+ return "0.0.0";
18
+ }
19
+ })();
20
+ /** Entry point. Returns the exit code; never throws. */
21
+ export async function main(argv, deps = {}) {
22
+ const registry = buildRegistry();
23
+ let parsed;
24
+ try {
25
+ parsed = parseGlobals(argv);
26
+ }
27
+ catch (err) {
28
+ const out = new Output({ stdout: deps.stdout, stderr: deps.stderr, env: deps.env });
29
+ return reportError(out, false, false, err);
30
+ }
31
+ const { flags, rest, passthrough } = parsed;
32
+ let ctx;
33
+ try {
34
+ ctx = createContext({ ...deps, argv, flags, passthrough, version: deps.version ?? VERSION, registry });
35
+ }
36
+ catch (err) {
37
+ const out = new Output({ stdout: deps.stdout, stderr: deps.stderr, env: deps.env, json: flags.json });
38
+ return reportError(out, flags.json, flags.debug, err);
39
+ }
40
+ try {
41
+ if (flags.version) {
42
+ if (flags.json)
43
+ ctx.out.jsonOut({ ok: true, version: ctx.version });
44
+ else
45
+ ctx.out.log(`scribeclock ${ctx.version}`);
46
+ return EXIT.OK;
47
+ }
48
+ if (rest.length === 0) {
49
+ renderGlobalHelp(ctx.out, registry, ctx.version, null);
50
+ return EXIT.OK;
51
+ }
52
+ const resolved = registry.resolve(rest);
53
+ if (resolved) {
54
+ if (flags.help && !resolved.command.allowUnknownFlags) {
55
+ renderCommandHelp(ctx.out, resolved.command);
56
+ return EXIT.OK;
57
+ }
58
+ if (flags.help && resolved.rest.length === 0) {
59
+ renderCommandHelp(ctx.out, resolved.command);
60
+ return EXIT.OK;
61
+ }
62
+ const inv = registry.invoke(resolved.command, resolved.words, resolved.rest, passthrough);
63
+ const code = (await resolved.command.run(ctx, inv)) ?? EXIT.OK;
64
+ updateNotice(ctx);
65
+ return code;
66
+ }
67
+ const word = rest[0];
68
+ if (word.startsWith("-"))
69
+ throw usageError(`Unknown option ${word}.`, "Run `scribeclock --help`.");
70
+ // A group word on its own ("scribeclock tasks") lists its subcommands.
71
+ const children = registry.children(word);
72
+ if (children.length && !/_/.test(word)) {
73
+ ctx.out.log(ctx.out.bold(`scribeclock ${word} <subcommand>`));
74
+ const w = Math.max(...children.map((c) => c.path.length));
75
+ for (const c of children)
76
+ ctx.out.log(` ${ctx.out.cyan(c.path.padEnd(w))} ${c.summary}`);
77
+ return flags.help ? EXIT.OK : EXIT.USAGE;
78
+ }
79
+ // Otherwise it may be a server tool, used directly as a command.
80
+ let code = null;
81
+ try {
82
+ code = await runGeneratedToolCommand(ctx, word, rest.slice(1));
83
+ }
84
+ catch (err) {
85
+ if (isCliError(err) && err.code === EXIT.AUTH && !/^[a-z][a-z0-9_-]*$/.test(word))
86
+ throw usageError(`Unknown command "${word}".`, "Run `scribeclock --help`.");
87
+ if (isCliError(err) && err.code === EXIT.AUTH) {
88
+ throw new CliError(`"${word}" is not a built-in command, and server tools need a login to be listed.`, { code: EXIT.AUTH, hint: "Run `scribeclock login` (or set SCRIBECLOCK_TOKEN), then try again. `scribeclock tools` shows the public tool names." });
89
+ }
90
+ throw err;
91
+ }
92
+ if (code !== null) {
93
+ updateNotice(ctx);
94
+ return code;
95
+ }
96
+ const candidates = [...registry.list().filter((c) => !c.hidden).map((c) => c.path.split(" ")[0]), ...(await ctx.tools().catch(() => [])).map((t) => t.name)];
97
+ const near = closest(word, Array.from(new Set(candidates)));
98
+ throw usageError(`Unknown command "${word}".`, near ? `Did you mean "${near}"? Run \`scribeclock --help\` for the list.` : "Run `scribeclock --help` for the list.");
99
+ }
100
+ catch (err) {
101
+ return reportError(ctx.out, flags.json, flags.debug, err);
102
+ }
103
+ }
104
+ function reportError(out, json, debug, err) {
105
+ if (isCliError(err)) {
106
+ if (json)
107
+ out.jsonOut({ ok: false, error: err.id, message: err.message, hint: err.hint, exit_code: err.code, details: err.details });
108
+ else {
109
+ out.error(err.message);
110
+ if (err.hint)
111
+ out.stderr.write(out.dim(indent(err.hint)) + "\n");
112
+ if (debug && err.details)
113
+ out.stderr.write(out.dim(JSON.stringify(err.details, null, 2)) + "\n");
114
+ }
115
+ return err.code;
116
+ }
117
+ const message = err instanceof Error ? err.message : String(err);
118
+ if (json)
119
+ out.jsonOut({ ok: false, error: "unexpected", message, exit_code: EXIT.ERROR });
120
+ else {
121
+ out.error(`Unexpected error: ${message}`);
122
+ if (debug && err instanceof Error && err.stack)
123
+ out.stderr.write(out.dim(err.stack) + "\n");
124
+ else
125
+ out.stderr.write(out.dim(" Re-run with --debug for the stack trace.") + "\n");
126
+ }
127
+ return EXIT.ERROR;
128
+ }
129
+ function indent(text) {
130
+ return text
131
+ .split("\n")
132
+ .map((l) => ` ${l}`)
133
+ .join("\n");
134
+ }
@@ -0,0 +1,97 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { writeJsonPrivate } from "../config.js";
5
+ import { describeServer } from "./client.js";
6
+ export const CATALOG_TTL_MS = 24 * 60 * 60 * 1000;
7
+ /**
8
+ * The server's tools/list, cached per endpoint + token so a new MCP tool shows up in the CLI without
9
+ * a release. `scribeclock refresh` forces a reload; a stale cache is used when the server is down.
10
+ */
11
+ export class ToolCatalog {
12
+ opts;
13
+ memo = null;
14
+ fingerprint;
15
+ constructor(opts) {
16
+ this.opts = opts;
17
+ this.fingerprint = opts.tokenFingerprint;
18
+ }
19
+ /** tools/list is filtered to the token's scopes, so the cache is keyed by (a hash of) the token. */
20
+ setTokenFingerprint(fp) {
21
+ if (fp !== this.fingerprint)
22
+ this.memo = null;
23
+ this.fingerprint = fp;
24
+ }
25
+ get cachePath() {
26
+ if (!this.fingerprint)
27
+ return null;
28
+ const key = createHash("sha256").update(`${this.opts.endpoint}\n${this.fingerprint}`).digest("hex").slice(0, 16);
29
+ return join(this.opts.cacheDir, `tools-${key}.json`);
30
+ }
31
+ readCache() {
32
+ const path = this.cachePath;
33
+ if (!path || !existsSync(path))
34
+ return null;
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
37
+ return parsed.version === 1 && Array.isArray(parsed.tools) ? parsed : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ isFresh(cache) {
44
+ const now = (this.opts.now ?? (() => new Date()))().getTime();
45
+ return now - new Date(cache.fetched_at).getTime() < (this.opts.ttlMs ?? CATALOG_TTL_MS);
46
+ }
47
+ async load(opts = {}) {
48
+ if (this.memo && !opts.force)
49
+ return this.memo;
50
+ const cache = this.readCache();
51
+ if (cache && !opts.force && this.isFresh(cache)) {
52
+ this.memo = cache.tools;
53
+ return cache.tools;
54
+ }
55
+ try {
56
+ const tools = await this.opts.listTools();
57
+ this.memo = tools;
58
+ const path = this.cachePath;
59
+ if (path)
60
+ writeJsonPrivate(path, { version: 1, endpoint: this.opts.endpoint, fetched_at: (this.opts.now ?? (() => new Date()))().toISOString(), tools });
61
+ return tools;
62
+ }
63
+ catch (err) {
64
+ if (cache) {
65
+ this.opts.warn?.(`Using the cached tool list from ${cache.fetched_at} (could not refresh: ${err instanceof Error ? err.message : String(err)}).`);
66
+ this.memo = cache.tools;
67
+ return cache.tools;
68
+ }
69
+ throw err;
70
+ }
71
+ }
72
+ /** Tool names from the public server description — works before login (no schemas). */
73
+ async namesOnly() {
74
+ const described = await describeServer(this.opts.endpoint, this.opts.fetch);
75
+ return Array.isArray(described.tools) ? described.tools.map(String) : [];
76
+ }
77
+ /** Remove every cached catalog for this endpoint (logout). */
78
+ clearAll() {
79
+ if (!existsSync(this.opts.cacheDir))
80
+ return;
81
+ for (const f of readdirSync(this.opts.cacheDir)) {
82
+ if (!f.startsWith("tools-") || !f.endsWith(".json"))
83
+ continue;
84
+ try {
85
+ const parsed = JSON.parse(readFileSync(join(this.opts.cacheDir, f), "utf8"));
86
+ if (parsed.endpoint === this.opts.endpoint)
87
+ unlinkSync(join(this.opts.cacheDir, f));
88
+ }
89
+ catch {
90
+ /* ignore unreadable cache files */
91
+ }
92
+ }
93
+ }
94
+ }
95
+ export function tokenFingerprint(token) {
96
+ return token ? createHash("sha256").update(token).digest("hex").slice(0, 24) : null;
97
+ }
@@ -0,0 +1,164 @@
1
+ import { CliError, authError, scopeError, usageError } from "../cli/errors.js";
2
+ export const PROTOCOL_VERSION = "2025-06-18";
3
+ /** Stateless MCP Streamable-HTTP client: one POST per JSON-RPC request, bearer token on each. */
4
+ export class McpClient {
5
+ endpoint;
6
+ tokens;
7
+ fetchImpl;
8
+ clientName;
9
+ clientVersion;
10
+ debug;
11
+ nextId = 1;
12
+ constructor(opts) {
13
+ this.endpoint = opts.endpoint;
14
+ this.tokens = opts.tokens;
15
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
16
+ this.clientName = opts.clientName ?? "scribeclock-cli";
17
+ this.clientVersion = opts.clientVersion ?? "0.0.0";
18
+ this.debug = opts.debug;
19
+ }
20
+ async post(token, body) {
21
+ const started = Date.now();
22
+ let res;
23
+ try {
24
+ res = await this.fetchImpl(this.endpoint, {
25
+ method: "POST",
26
+ headers: {
27
+ "content-type": "application/json",
28
+ accept: "application/json, text/event-stream",
29
+ "mcp-protocol-version": PROTOCOL_VERSION,
30
+ authorization: `Bearer ${token}`,
31
+ "user-agent": `${this.clientName}/${this.clientVersion}`,
32
+ },
33
+ body: JSON.stringify(body),
34
+ });
35
+ }
36
+ catch (err) {
37
+ const cause = err instanceof Error ? err.cause?.message ?? err.message : String(err);
38
+ throw new CliError(`Could not reach ${this.endpoint} (${cause}).`, {
39
+ id: "network",
40
+ hint: "Check your network connection. If you meant another server, set SCRIBECLOCK_URL or pass --url.",
41
+ });
42
+ }
43
+ this.debug?.(`${body.method} → ${res.status} in ${Date.now() - started}ms`);
44
+ return res;
45
+ }
46
+ async rpc(method, params = {}) {
47
+ let token = await this.tokens.getToken();
48
+ if (!token)
49
+ throw authError();
50
+ const body = { jsonrpc: "2.0", id: this.nextId++, method, params };
51
+ let res = await this.post(token, body);
52
+ if (res.status === 401) {
53
+ const refreshed = await this.tokens.refresh();
54
+ if (!refreshed) {
55
+ throw authError("Your ScribeClock session is no longer valid (the token was rejected).", "Run `scribeclock login` again. If you use SCRIBECLOCK_TOKEN, create a new personal access token.");
56
+ }
57
+ token = refreshed;
58
+ res = await this.post(token, body);
59
+ if (res.status === 401)
60
+ throw authError("The server rejected the token even after refreshing it.", "Run `scribeclock logout` and then `scribeclock login`.");
61
+ }
62
+ if (res.status === 429) {
63
+ const retry = Math.min(5, Number(res.headers.get("retry-after") ?? "2") || 2);
64
+ await new Promise((r) => setTimeout(r, retry * 1000));
65
+ res = await this.post(token, body);
66
+ }
67
+ if (res.status === 202)
68
+ return undefined;
69
+ const text = await res.text();
70
+ let parsed = null;
71
+ try {
72
+ parsed = text ? JSON.parse(text) : null;
73
+ }
74
+ catch {
75
+ parsed = null;
76
+ }
77
+ if (res.status >= 400 || !parsed) {
78
+ const detail = parsed && !Array.isArray(parsed) && parsed.error ? parsed.error.message : text.slice(0, 200);
79
+ throw new CliError(`ScribeClock answered ${res.status}${detail ? `: ${detail}` : ""}.`, {
80
+ id: "server_error",
81
+ hint: res.status >= 500 ? "The server had a problem. Try again in a moment; `scribeclock doctor` checks reachability." : undefined,
82
+ });
83
+ }
84
+ const msg = Array.isArray(parsed) ? parsed[0] : parsed;
85
+ if (msg.error) {
86
+ const { code, message } = msg.error;
87
+ if (code === -32602)
88
+ throw usageError(message);
89
+ if (code === -32601)
90
+ throw new CliError(`The server does not support ${method}: ${message}`, { id: "method_not_found" });
91
+ throw new CliError(message, { id: "rpc_error", details: { code } });
92
+ }
93
+ return msg.result;
94
+ }
95
+ async initialize() {
96
+ return await this.rpc("initialize", {
97
+ protocolVersion: PROTOCOL_VERSION,
98
+ capabilities: {},
99
+ clientInfo: { name: this.clientName, version: this.clientVersion },
100
+ });
101
+ }
102
+ async listTools() {
103
+ const result = await this.rpc("tools/list");
104
+ return Array.isArray(result?.tools) ? result.tools : [];
105
+ }
106
+ async callTool(name, args) {
107
+ const result = await this.rpc("tools/call", { name, arguments: args });
108
+ const outcome = parseToolResult(result);
109
+ if (!outcome.ok) {
110
+ if (outcome.error === "insufficient_scope") {
111
+ const d = outcome.data;
112
+ throw scopeError(typeof d.required_scope === "string" ? d.required_scope : undefined, Array.isArray(d.granted_scopes) ? d.granted_scopes.map(String) : undefined);
113
+ }
114
+ if (outcome.error === "invalid_token")
115
+ throw authError("The token was rejected.", "Run `scribeclock login` again.");
116
+ }
117
+ return outcome;
118
+ }
119
+ }
120
+ /** structuredContent wins; otherwise the first text block is parsed as JSON; otherwise the text itself. */
121
+ export function parseToolResult(result) {
122
+ const content = Array.isArray(result?.content) ? result.content : [];
123
+ let data = result?.structuredContent && typeof result.structuredContent === "object" ? result.structuredContent : null;
124
+ if (!data) {
125
+ const text = content.find((c) => c.type === "text")?.text;
126
+ if (text) {
127
+ try {
128
+ const parsed = JSON.parse(text);
129
+ data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { value: parsed };
130
+ }
131
+ catch {
132
+ const m = /^Error \(([a-z_]+)\): (.*)$/s.exec(text);
133
+ data = m ? { ok: false, error: m[1], message: m[2] } : { text };
134
+ }
135
+ }
136
+ else {
137
+ data = {};
138
+ }
139
+ }
140
+ const isError = result?.isError === true || data.ok === false;
141
+ return {
142
+ ok: !isError,
143
+ data,
144
+ content,
145
+ error: isError ? (typeof data.error === "string" ? data.error : "error") : undefined,
146
+ message: isError ? (typeof data.message === "string" ? data.message : "The request failed.") : undefined,
147
+ };
148
+ }
149
+ /** `GET <endpoint>` — the server's self-description (tool names, connect snippets). No auth. */
150
+ export async function describeServer(endpoint, fetchImpl = globalThis.fetch) {
151
+ let res;
152
+ try {
153
+ res = await fetchImpl(endpoint, { headers: { accept: "application/json" } });
154
+ }
155
+ catch (err) {
156
+ throw new CliError(`Could not reach ${endpoint} (${err instanceof Error ? err.message : String(err)}).`, { id: "network" });
157
+ }
158
+ if (!res.ok)
159
+ throw new CliError(`${endpoint} answered ${res.status}.`, { id: "server_error" });
160
+ const json = (await res.json());
161
+ if (json.transport !== "streamable-http")
162
+ throw new CliError(`${endpoint} does not look like a ScribeClock MCP server.`, { id: "not_mcp" });
163
+ return json;
164
+ }