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.
- package/README.md +148 -2
- package/bin/scribeclock.js +21 -0
- package/dist/auth/oauth.js +230 -0
- package/dist/auth/store.js +212 -0
- package/dist/auth/token.js +77 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/errors.js +48 -0
- package/dist/cli/output.js +185 -0
- package/dist/cli/parser.js +230 -0
- package/dist/cli/policy.js +49 -0
- package/dist/cli/registry.js +138 -0
- package/dist/commands/_shared.js +220 -0
- package/dist/commands/auth.js +228 -0
- package/dist/commands/automation.js +420 -0
- package/dist/commands/cf.js +270 -0
- package/dist/commands/clock.js +189 -0
- package/dist/commands/files.js +210 -0
- package/dist/commands/gitflow.js +208 -0
- package/dist/commands/index.js +35 -0
- package/dist/commands/meetings.js +254 -0
- package/dist/commands/meta.js +129 -0
- package/dist/commands/ops.js +324 -0
- package/dist/commands/projects.js +354 -0
- package/dist/commands/tasks.js +492 -0
- package/dist/commands/team.js +190 -0
- package/dist/commands/tools.js +257 -0
- package/dist/commands/vault.js +133 -0
- package/dist/commands/workflow.js +525 -0
- package/dist/config.js +102 -0
- package/dist/context.js +125 -0
- package/dist/git.js +144 -0
- package/dist/index.js +134 -0
- package/dist/mcp/catalog.js +97 -0
- package/dist/mcp/client.js +164 -0
- package/dist/mcp/schema.js +242 -0
- package/dist/render.js +117 -0
- package/dist/types.js +9 -0
- package/dist/update-check.js +28 -0
- package/dist/update.js +47 -0
- package/dist/util/format.js +176 -0
- package/package.json +35 -4
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { usageError } from "../cli/errors.js";
|
|
2
|
+
import { closest } from "../cli/parser.js";
|
|
3
|
+
export function kebab(name) {
|
|
4
|
+
return name.replace(/_/g, "-");
|
|
5
|
+
}
|
|
6
|
+
export function snake(name) {
|
|
7
|
+
return name.replace(/-/g, "_");
|
|
8
|
+
}
|
|
9
|
+
export function schemaTypes(prop) {
|
|
10
|
+
if (!prop || prop.type === undefined)
|
|
11
|
+
return ["string"];
|
|
12
|
+
return Array.isArray(prop.type) ? prop.type.map(String) : [String(prop.type)];
|
|
13
|
+
}
|
|
14
|
+
/** Flags a tool accepts, from its inputSchema (required ones first, then alphabetical). */
|
|
15
|
+
export function schemaFlags(schema) {
|
|
16
|
+
const props = schema?.properties ?? {};
|
|
17
|
+
const required = new Set(schema?.required ?? []);
|
|
18
|
+
const out = Object.entries(props).map(([name, prop]) => {
|
|
19
|
+
const types = schemaTypes(prop);
|
|
20
|
+
return {
|
|
21
|
+
name,
|
|
22
|
+
types: types.filter((t) => t !== "null"),
|
|
23
|
+
nullable: types.includes("null"),
|
|
24
|
+
description: prop.description ?? "",
|
|
25
|
+
required: required.has(name),
|
|
26
|
+
enum: prop.enum,
|
|
27
|
+
default: prop.default,
|
|
28
|
+
items: prop.items,
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
out.sort((a, b) => (a.required === b.required ? a.name.localeCompare(b.name) : a.required ? -1 : 1));
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Turns `--key value`, `--key=value`, `key=value`, `--flag` / `--no-flag`, repeated flags and bare
|
|
36
|
+
* positionals into typed tool arguments according to the tool's inputSchema.
|
|
37
|
+
*
|
|
38
|
+
* Positionals fill the schema's `required` properties in order (so `get_task #12` works); extra
|
|
39
|
+
* positionals are an error. `--args '{json}'` (or `--json '{json}'`) merges a JSON object in.
|
|
40
|
+
*/
|
|
41
|
+
export function parseToolArgs(tokens, schema, sources = {}) {
|
|
42
|
+
const props = schema?.properties ?? {};
|
|
43
|
+
const required = schema?.required ?? [];
|
|
44
|
+
const args = {};
|
|
45
|
+
const positionals = [];
|
|
46
|
+
const resolveKey = (raw) => {
|
|
47
|
+
const k = snake(raw);
|
|
48
|
+
if (k in props)
|
|
49
|
+
return k;
|
|
50
|
+
const alt = Object.keys(props).find((p) => p.toLowerCase() === k.toLowerCase());
|
|
51
|
+
if (alt)
|
|
52
|
+
return alt;
|
|
53
|
+
if (schema?.additionalProperties === false) {
|
|
54
|
+
const near = closest(k, Object.keys(props));
|
|
55
|
+
throw usageError(`Unknown argument "${raw}" for this tool.`, near ? `Did you mean --${kebab(near)}?` : `Run with --help to see the accepted arguments.`);
|
|
56
|
+
}
|
|
57
|
+
return k;
|
|
58
|
+
};
|
|
59
|
+
const setValue = (key, raw, negated = false) => {
|
|
60
|
+
const prop = props[key];
|
|
61
|
+
const types = schemaTypes(prop);
|
|
62
|
+
const isBool = types.includes("boolean");
|
|
63
|
+
if (raw === undefined) {
|
|
64
|
+
if (!isBool)
|
|
65
|
+
throw usageError(`--${kebab(key)} needs a value.`);
|
|
66
|
+
args[key] = !negated;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const expanded = expandFileRef(raw, sources);
|
|
70
|
+
const value = coerceValue(expanded, prop, key);
|
|
71
|
+
if (types.includes("array") && !Array.isArray(value)) {
|
|
72
|
+
const prev = Array.isArray(args[key]) ? args[key] : [];
|
|
73
|
+
args[key] = [...prev, value];
|
|
74
|
+
}
|
|
75
|
+
else if (types.includes("array") && Array.isArray(args[key]) && Array.isArray(value)) {
|
|
76
|
+
args[key] = [...args[key], ...value];
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
args[key] = value;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
83
|
+
const t = tokens[i];
|
|
84
|
+
if (t === "--args" || t === "--json" || t.startsWith("--args=") || t.startsWith("--json=")) {
|
|
85
|
+
const eq = t.indexOf("=");
|
|
86
|
+
const raw = eq > 0 ? t.slice(eq + 1) : tokens[++i];
|
|
87
|
+
if (raw === undefined)
|
|
88
|
+
throw usageError(`${t} needs a JSON object.`);
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(expandFileRef(raw, sources));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw usageError(`${t.split("=")[0]} must be a JSON object, e.g. --args '{"task_id":"#12"}'.`);
|
|
95
|
+
}
|
|
96
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
97
|
+
throw usageError(`${t.split("=")[0]} must be a JSON object.`);
|
|
98
|
+
for (const [k, v] of Object.entries(parsed))
|
|
99
|
+
args[resolveKey(k)] = v;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (t.startsWith("--") && t.length > 2) {
|
|
103
|
+
const eq = t.indexOf("=");
|
|
104
|
+
const rawKey = (eq > 0 ? t.slice(2, eq) : t.slice(2)).trim();
|
|
105
|
+
const inline = eq > 0 ? t.slice(eq + 1) : undefined;
|
|
106
|
+
let negated = false;
|
|
107
|
+
let key;
|
|
108
|
+
if (rawKey.startsWith("no-") && !(snake(rawKey) in props) && snake(rawKey.slice(3)) in props) {
|
|
109
|
+
key = snake(rawKey.slice(3));
|
|
110
|
+
negated = true;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
key = resolveKey(rawKey);
|
|
114
|
+
}
|
|
115
|
+
if (negated) {
|
|
116
|
+
setValue(key, undefined, true);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (inline !== undefined) {
|
|
120
|
+
setValue(key, inline);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const next = tokens[i + 1];
|
|
124
|
+
const isBool = schemaTypes(props[key]).includes("boolean");
|
|
125
|
+
if (next === undefined || (next.startsWith("--") && next.length > 2) || (isBool && !/^(true|false|yes|no|1|0)$/i.test(next))) {
|
|
126
|
+
setValue(key, undefined);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
setValue(key, next);
|
|
130
|
+
i++;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_-]*)=(.*)$/s.exec(t);
|
|
135
|
+
if (kv && !t.startsWith("#")) {
|
|
136
|
+
setValue(resolveKey(kv[1]), kv[2]);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
positionals.push(t);
|
|
140
|
+
}
|
|
141
|
+
// Positionals → required properties that are still missing, in schema order.
|
|
142
|
+
const slots = required.filter((r) => !(r in args));
|
|
143
|
+
for (const p of positionals) {
|
|
144
|
+
const slot = slots.shift();
|
|
145
|
+
if (!slot) {
|
|
146
|
+
// A single optional "identifier-like" property can take the positional too (e.g. list_tasks query).
|
|
147
|
+
const fallback = ["query", "work", "repo", "project_id", "task_id", "file_id", "meeting_id", "zone", "path"].find((k) => k in props && !(k in args));
|
|
148
|
+
if (!fallback)
|
|
149
|
+
throw usageError(`Unexpected argument "${p}".`, "Pass arguments as --name value or name=value.");
|
|
150
|
+
setValue(fallback, p);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
setValue(slot, p);
|
|
154
|
+
}
|
|
155
|
+
return args;
|
|
156
|
+
}
|
|
157
|
+
function expandFileRef(raw, sources) {
|
|
158
|
+
if (raw === "@-" && sources.readStdin)
|
|
159
|
+
return sources.readStdin();
|
|
160
|
+
if (raw.startsWith("@") && raw.length > 1 && sources.readFile && !raw.startsWith("@@")) {
|
|
161
|
+
try {
|
|
162
|
+
return sources.readFile(raw.slice(1));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw usageError(`Could not read file "${raw.slice(1)}".`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return raw.startsWith("@@") ? raw.slice(1) : raw;
|
|
169
|
+
}
|
|
170
|
+
/** String → the type the schema wants. Objects/arrays are JSON; arrays also accept comma lists. */
|
|
171
|
+
export function coerceValue(raw, prop, key = "value") {
|
|
172
|
+
const types = schemaTypes(prop);
|
|
173
|
+
const trimmed = raw.trim();
|
|
174
|
+
if (types.includes("null") && /^null$/i.test(trimmed))
|
|
175
|
+
return null;
|
|
176
|
+
if (types.includes("boolean") && /^(true|false|yes|no|1|0|on|off)$/i.test(trimmed) && !types.includes("string")) {
|
|
177
|
+
return /^(true|yes|1|on)$/i.test(trimmed);
|
|
178
|
+
}
|
|
179
|
+
if (types.includes("integer") || types.includes("number")) {
|
|
180
|
+
if (/^-?\d+(\.\d+)?([eE][-+]?\d+)?$/.test(trimmed)) {
|
|
181
|
+
const n = Number(trimmed);
|
|
182
|
+
if (types.includes("integer") && !Number.isInteger(n))
|
|
183
|
+
throw usageError(`--${kebab(key)} must be a whole number (got "${raw}").`);
|
|
184
|
+
checkRange(n, prop, key);
|
|
185
|
+
return n;
|
|
186
|
+
}
|
|
187
|
+
if (!types.includes("string"))
|
|
188
|
+
throw usageError(`--${kebab(key)} must be a number (got "${raw}").`);
|
|
189
|
+
}
|
|
190
|
+
if (types.includes("object")) {
|
|
191
|
+
if (/^\s*\{/.test(trimmed))
|
|
192
|
+
return parseJson(trimmed, key);
|
|
193
|
+
if (!types.includes("string"))
|
|
194
|
+
throw usageError(`--${kebab(key)} must be a JSON object.`);
|
|
195
|
+
}
|
|
196
|
+
if (types.includes("array")) {
|
|
197
|
+
if (/^\s*\[/.test(trimmed))
|
|
198
|
+
return parseJson(trimmed, key);
|
|
199
|
+
const itemTypes = schemaTypes(prop?.items);
|
|
200
|
+
const parts = trimmed.split(",").map((s) => s.trim()).filter(Boolean);
|
|
201
|
+
if (itemTypes.includes("object"))
|
|
202
|
+
throw usageError(`--${kebab(key)} expects a JSON array of objects.`);
|
|
203
|
+
return parts.map((p) => (itemTypes.includes("integer") || itemTypes.includes("number") ? Number(p) : itemTypes.includes("boolean") ? /^(true|yes|1)$/i.test(p) : p));
|
|
204
|
+
}
|
|
205
|
+
if (prop?.enum && !prop.enum.map(String).includes(trimmed)) {
|
|
206
|
+
const near = closest(trimmed, prop.enum.map(String));
|
|
207
|
+
throw usageError(`--${kebab(key)} must be one of: ${prop.enum.join(", ")} (got "${raw}").`, near ? `Did you mean "${near}"?` : undefined);
|
|
208
|
+
}
|
|
209
|
+
return raw;
|
|
210
|
+
}
|
|
211
|
+
function checkRange(n, prop, key) {
|
|
212
|
+
if (prop?.minimum !== undefined && n < prop.minimum)
|
|
213
|
+
throw usageError(`--${kebab(key)} must be at least ${prop.minimum}.`);
|
|
214
|
+
if (prop?.maximum !== undefined && n > prop.maximum)
|
|
215
|
+
throw usageError(`--${kebab(key)} must be at most ${prop.maximum}.`);
|
|
216
|
+
}
|
|
217
|
+
function parseJson(text, key) {
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(text);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
throw usageError(`--${kebab(key)} is not valid JSON.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Missing required arguments, with their descriptions, for a usage error. */
|
|
226
|
+
export function missingRequired(args, schema) {
|
|
227
|
+
return (schema?.required ?? []).filter((r) => args[r] === undefined);
|
|
228
|
+
}
|
|
229
|
+
export function requireArgs(args, schema, toolName) {
|
|
230
|
+
const missing = missingRequired(args, schema);
|
|
231
|
+
if (!missing.length)
|
|
232
|
+
return;
|
|
233
|
+
const props = schema?.properties ?? {};
|
|
234
|
+
const lines = missing.map((m) => ` --${kebab(m)} ${firstSentence(props[m]?.description ?? "")}`.trimEnd());
|
|
235
|
+
throw usageError(`${toolName}: missing ${missing.length === 1 ? "argument" : "arguments"} ${missing.map((m) => `--${kebab(m)}`).join(", ")}.`, lines.join("\n"));
|
|
236
|
+
}
|
|
237
|
+
export function firstSentence(text, max = 160) {
|
|
238
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
239
|
+
const m = /^(.*?[.!?])(\s|$)/.exec(flat);
|
|
240
|
+
const s = m ? m[1] : flat;
|
|
241
|
+
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
|
242
|
+
}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { formatCell } from "./cli/output.js";
|
|
2
|
+
const PREFERRED_COLUMNS = ["key", "id", "number", "title", "name", "status", "priority", "assignee", "due_date", "project", "updated_at", "created_at", "email", "role", "type", "content", "url"];
|
|
3
|
+
const HIDDEN_KEYS = new Set(["ok", "hint"]);
|
|
4
|
+
/**
|
|
5
|
+
* Generic human rendering for any tool result: scalars as key/value lines, arrays of records as
|
|
6
|
+
* tables, nested objects indented, anything deeper as JSON. Friendly commands have their own
|
|
7
|
+
* renderers; this is what `scribeclock <tool>` prints.
|
|
8
|
+
*/
|
|
9
|
+
export function renderResult(out, data, opts = {}) {
|
|
10
|
+
const scalars = [];
|
|
11
|
+
const complex = [];
|
|
12
|
+
for (const [k, v] of Object.entries(data)) {
|
|
13
|
+
if (HIDDEN_KEYS.has(k) || v === undefined || v === null)
|
|
14
|
+
continue;
|
|
15
|
+
if (isScalar(v) || (Array.isArray(v) && v.every(isScalar)))
|
|
16
|
+
scalars.push([k, v]);
|
|
17
|
+
else
|
|
18
|
+
complex.push([k, v]);
|
|
19
|
+
}
|
|
20
|
+
if (scalars.length)
|
|
21
|
+
out.kv(scalars, 0);
|
|
22
|
+
for (const [k, v] of complex) {
|
|
23
|
+
out.log("");
|
|
24
|
+
out.log(out.bold(k) + (Array.isArray(v) ? out.dim(` (${v.length})`) : ""));
|
|
25
|
+
renderValue(out, v, 1);
|
|
26
|
+
}
|
|
27
|
+
if (opts.showHint !== false && typeof data.hint === "string" && !out.quiet) {
|
|
28
|
+
out.log("");
|
|
29
|
+
out.log(out.dim(data.hint));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function renderValue(out, v, depth) {
|
|
33
|
+
const indent = " ".repeat(depth);
|
|
34
|
+
if (Array.isArray(v)) {
|
|
35
|
+
if (v.length === 0) {
|
|
36
|
+
out.log(`${indent}${out.dim("(none)")}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (v.every((x) => x && typeof x === "object" && !Array.isArray(x))) {
|
|
40
|
+
const rows = v;
|
|
41
|
+
const columns = pickColumns(rows);
|
|
42
|
+
if (columns.length) {
|
|
43
|
+
out.table(columns, rows.map((r) => columns.map((c) => cellOf(r[c]))), { indent: depth * 2 });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const item of v) {
|
|
48
|
+
if (isScalar(item))
|
|
49
|
+
out.log(`${indent}- ${formatCell(item)}`);
|
|
50
|
+
else {
|
|
51
|
+
out.log(`${indent}-`);
|
|
52
|
+
renderValue(out, item, depth + 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (v && typeof v === "object") {
|
|
58
|
+
const entries = Object.entries(v).filter(([, x]) => x !== undefined && x !== null);
|
|
59
|
+
const scalars = entries.filter(([, x]) => isScalar(x) || (Array.isArray(x) && x.every(isScalar)));
|
|
60
|
+
const nested = entries.filter(([, x]) => !(isScalar(x) || (Array.isArray(x) && x.every(isScalar))));
|
|
61
|
+
if (scalars.length)
|
|
62
|
+
out.kv(scalars, depth * 2);
|
|
63
|
+
for (const [k, x] of nested) {
|
|
64
|
+
if (depth >= 3) {
|
|
65
|
+
out.log(`${indent}${out.dim(k)} ${JSON.stringify(x)}`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
out.log(`${indent}${out.dim(k)}${Array.isArray(x) ? out.dim(` (${x.length})`) : ""}`);
|
|
69
|
+
renderValue(out, x, depth + 1);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
out.log(`${indent}${formatCell(v)}`);
|
|
74
|
+
}
|
|
75
|
+
export function isScalar(v) {
|
|
76
|
+
return v === null || v === undefined || ["string", "number", "boolean"].includes(typeof v);
|
|
77
|
+
}
|
|
78
|
+
/** Columns for a table: preferred names first, then other scalar keys, at most 7. */
|
|
79
|
+
export function pickColumns(rows) {
|
|
80
|
+
const seen = new Map();
|
|
81
|
+
for (const r of rows)
|
|
82
|
+
for (const [k, v] of Object.entries(r))
|
|
83
|
+
if (isScalar(v) || isNamedRef(v))
|
|
84
|
+
seen.set(k, (seen.get(k) ?? 0) + 1);
|
|
85
|
+
const keys = [...seen.keys()];
|
|
86
|
+
const preferred = PREFERRED_COLUMNS.filter((k) => keys.includes(k));
|
|
87
|
+
const others = keys.filter((k) => !preferred.includes(k) && !/_id$|^id$|_at$|^url$/.test(k));
|
|
88
|
+
const cols = [...preferred, ...others].filter((k) => k !== "id" || !keys.includes("key"));
|
|
89
|
+
return cols.slice(0, 7);
|
|
90
|
+
}
|
|
91
|
+
/** { id, name } / { key, title } style references render as their label. */
|
|
92
|
+
function isNamedRef(v) {
|
|
93
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
94
|
+
return false;
|
|
95
|
+
const o = v;
|
|
96
|
+
return typeof o.name === "string" || typeof o.title === "string" || typeof o.email === "string";
|
|
97
|
+
}
|
|
98
|
+
export function cellOf(v) {
|
|
99
|
+
if (isNamedRef(v)) {
|
|
100
|
+
const o = v;
|
|
101
|
+
return String(o.key ? `${o.key} ` : "") + String(o.name ?? o.title ?? o.email ?? "");
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(v))
|
|
104
|
+
return v.map(cellOf).join(", ");
|
|
105
|
+
return formatCell(v);
|
|
106
|
+
}
|
|
107
|
+
/** Non-text content blocks (images, files) get a one-line description. */
|
|
108
|
+
export function describeContent(out, content) {
|
|
109
|
+
for (const block of content) {
|
|
110
|
+
if (block.type === "image")
|
|
111
|
+
out.log(out.dim(`[image ${block.mimeType}, ${Math.round((block.data.length * 3) / 4 / 1024)} KB — use --json for the base64, or files get to save it]`));
|
|
112
|
+
else if (block.type === "resource_link")
|
|
113
|
+
out.log(out.dim(`[link] ${block.name}: ${block.uri}`));
|
|
114
|
+
else if (block.type === "resource")
|
|
115
|
+
out.log(out.dim(`[resource] ${block.resource.uri}${block.resource.mimeType ? ` (${block.resource.mimeType})` : ""}`));
|
|
116
|
+
}
|
|
117
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background update check, run detached by update.ts: fetches the latest published version and
|
|
3
|
+
* writes it into config.json. Never prints anything.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { writeJsonPrivate } from "./config.js";
|
|
7
|
+
const configPath = process.argv[2];
|
|
8
|
+
if (configPath) {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch("https://registry.npmjs.org/scribeclock/latest", { signal: AbortSignal.timeout(4000), headers: { accept: "application/json" } });
|
|
11
|
+
if (res.ok) {
|
|
12
|
+
const json = (await res.json());
|
|
13
|
+
let current = {};
|
|
14
|
+
try {
|
|
15
|
+
current = JSON.parse(readFileSync(configPath, "utf8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
current = {};
|
|
19
|
+
}
|
|
20
|
+
if (typeof json.version === "string") {
|
|
21
|
+
writeJsonPrivate(configPath, { ...current, update_check: { checked_at: new Date().toISOString(), latest: json.version } });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* offline or registry down: try again tomorrow */
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
/** Compare "1.2.3" style versions; true when `latest` is newer than `current`. */
|
|
6
|
+
export function isNewer(latest, current) {
|
|
7
|
+
const a = latest.split(/[.-]/).map((x) => parseInt(x, 10));
|
|
8
|
+
const b = current.split(/[.-]/).map((x) => parseInt(x, 10));
|
|
9
|
+
for (let i = 0; i < 3; i++) {
|
|
10
|
+
const x = a[i] ?? 0;
|
|
11
|
+
const y = b[i] ?? 0;
|
|
12
|
+
if (Number.isNaN(x) || Number.isNaN(y))
|
|
13
|
+
return false;
|
|
14
|
+
if (x !== y)
|
|
15
|
+
return x > y;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Prints a one-line notice when a newer version was found by a previous background check, then
|
|
21
|
+
* spawns a detached check (at most once a day) so this run never waits on the network.
|
|
22
|
+
*/
|
|
23
|
+
export function updateNotice(ctx) {
|
|
24
|
+
if (ctx.flags.json || ctx.flags.quiet || !ctx.out.isTTY)
|
|
25
|
+
return;
|
|
26
|
+
if (ctx.env.CI || ctx.env.SCRIBECLOCK_NO_UPDATE_CHECK)
|
|
27
|
+
return;
|
|
28
|
+
const cfg = ctx.config.read();
|
|
29
|
+
const last = cfg.update_check;
|
|
30
|
+
if (last?.latest && isNewer(last.latest, ctx.version)) {
|
|
31
|
+
ctx.out.info(ctx.out.dim(`Update available: scribeclock ${ctx.version} → ${last.latest} (npm install -g scribeclock@latest)`));
|
|
32
|
+
}
|
|
33
|
+
const checkedAt = last?.checked_at ? new Date(last.checked_at).getTime() : 0;
|
|
34
|
+
if (ctx.now().getTime() - checkedAt < DAY_MS)
|
|
35
|
+
return;
|
|
36
|
+
// Record the attempt first so concurrent runs do not all spawn a checker.
|
|
37
|
+
ctx.config.write({ update_check: { checked_at: ctx.now().toISOString(), latest: last?.latest } });
|
|
38
|
+
try {
|
|
39
|
+
const script = join(dirname(fileURLToPath(import.meta.url)), "update-check.js");
|
|
40
|
+
const child = spawn(process.execPath, [script, ctx.config.path], { detached: true, stdio: "ignore" });
|
|
41
|
+
child.on("error", () => undefined);
|
|
42
|
+
child.unref();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* never let the update check affect the command */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** Small formatting helpers shared by commands. */
|
|
2
|
+
export function fmtDuration(seconds, opts = {}) {
|
|
3
|
+
if (seconds === null || seconds === undefined || !Number.isFinite(seconds))
|
|
4
|
+
return "";
|
|
5
|
+
const s = Math.max(0, Math.floor(seconds));
|
|
6
|
+
const h = Math.floor(s / 3600);
|
|
7
|
+
const m = Math.floor((s % 3600) / 60);
|
|
8
|
+
if (opts.compact)
|
|
9
|
+
return h ? `${h}h${String(m).padStart(2, "0")}m` : `${m}m`;
|
|
10
|
+
if (h)
|
|
11
|
+
return `${h}h ${m}m`;
|
|
12
|
+
if (m)
|
|
13
|
+
return `${m}m`;
|
|
14
|
+
return `${s}s`;
|
|
15
|
+
}
|
|
16
|
+
export function fmtHours(seconds) {
|
|
17
|
+
return (seconds / 3600).toFixed(2);
|
|
18
|
+
}
|
|
19
|
+
export function fmtClock(seconds) {
|
|
20
|
+
const s = Math.max(0, Math.floor(seconds));
|
|
21
|
+
const h = Math.floor(s / 3600);
|
|
22
|
+
const m = Math.floor((s % 3600) / 60);
|
|
23
|
+
const sec = s % 60;
|
|
24
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
|
25
|
+
}
|
|
26
|
+
export function fmtDate(iso, opts = {}) {
|
|
27
|
+
if (!iso)
|
|
28
|
+
return "";
|
|
29
|
+
const d = new Date(iso);
|
|
30
|
+
if (Number.isNaN(d.getTime()))
|
|
31
|
+
return String(iso);
|
|
32
|
+
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
33
|
+
if (!opts.time)
|
|
34
|
+
return date;
|
|
35
|
+
return `${date} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
36
|
+
}
|
|
37
|
+
export function relTime(iso, now = new Date()) {
|
|
38
|
+
if (!iso)
|
|
39
|
+
return "";
|
|
40
|
+
const d = new Date(iso);
|
|
41
|
+
if (Number.isNaN(d.getTime()))
|
|
42
|
+
return String(iso);
|
|
43
|
+
const diff = (d.getTime() - now.getTime()) / 1000;
|
|
44
|
+
const abs = Math.abs(diff);
|
|
45
|
+
const [n, unit] = abs < 60 ? [Math.round(abs), "s"] : abs < 3600 ? [Math.round(abs / 60), "m"] : abs < 86400 ? [Math.round(abs / 3600), "h"] : [Math.round(abs / 86400), "d"];
|
|
46
|
+
return diff < 0 ? `${n}${unit} ago` : `in ${n}${unit}`;
|
|
47
|
+
}
|
|
48
|
+
export function dueLabel(iso, now = new Date()) {
|
|
49
|
+
if (!iso)
|
|
50
|
+
return "";
|
|
51
|
+
const d = new Date(iso);
|
|
52
|
+
if (Number.isNaN(d.getTime()))
|
|
53
|
+
return String(iso);
|
|
54
|
+
const days = Math.round((startOfDay(d).getTime() - startOfDay(now).getTime()) / 86400000);
|
|
55
|
+
if (days < 0)
|
|
56
|
+
return `overdue ${-days}d`;
|
|
57
|
+
if (days === 0)
|
|
58
|
+
return "today";
|
|
59
|
+
if (days === 1)
|
|
60
|
+
return "tomorrow";
|
|
61
|
+
return fmtDate(iso);
|
|
62
|
+
}
|
|
63
|
+
export function startOfDay(d) {
|
|
64
|
+
const x = new Date(d);
|
|
65
|
+
x.setHours(0, 0, 0, 0);
|
|
66
|
+
return x;
|
|
67
|
+
}
|
|
68
|
+
export function endOfDay(d) {
|
|
69
|
+
const x = startOfDay(d);
|
|
70
|
+
x.setDate(x.getDate() + 1);
|
|
71
|
+
return x;
|
|
72
|
+
}
|
|
73
|
+
export function startOfWeek(d) {
|
|
74
|
+
const x = startOfDay(d);
|
|
75
|
+
const day = (x.getDay() + 6) % 7; // Monday = 0
|
|
76
|
+
x.setDate(x.getDate() - day);
|
|
77
|
+
return x;
|
|
78
|
+
}
|
|
79
|
+
export function startOfMonth(d) {
|
|
80
|
+
const x = startOfDay(d);
|
|
81
|
+
x.setDate(1);
|
|
82
|
+
return x;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Human date/time input → Date. Accepts ISO-8601, YYYY-MM-DD, HH:MM (today, local), "yesterday",
|
|
86
|
+
* "today", "tomorrow", "now", and relative "7d" / "2h" / "30m" (that long ago).
|
|
87
|
+
*/
|
|
88
|
+
export function parseWhen(input, now = new Date()) {
|
|
89
|
+
const s = input.trim().toLowerCase();
|
|
90
|
+
if (!s)
|
|
91
|
+
return null;
|
|
92
|
+
if (s === "now")
|
|
93
|
+
return new Date(now);
|
|
94
|
+
if (s === "today")
|
|
95
|
+
return startOfDay(now);
|
|
96
|
+
if (s === "yesterday") {
|
|
97
|
+
const d = startOfDay(now);
|
|
98
|
+
d.setDate(d.getDate() - 1);
|
|
99
|
+
return d;
|
|
100
|
+
}
|
|
101
|
+
if (s === "tomorrow") {
|
|
102
|
+
const d = startOfDay(now);
|
|
103
|
+
d.setDate(d.getDate() + 1);
|
|
104
|
+
return d;
|
|
105
|
+
}
|
|
106
|
+
const rel = /^(\d+)\s*([mhdw])$/.exec(s);
|
|
107
|
+
if (rel) {
|
|
108
|
+
const n = Number(rel[1]);
|
|
109
|
+
const ms = { m: 60000, h: 3600000, d: 86400000, w: 7 * 86400000 }[rel[2]];
|
|
110
|
+
return new Date(now.getTime() - n * ms);
|
|
111
|
+
}
|
|
112
|
+
const hm = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(s);
|
|
113
|
+
if (hm) {
|
|
114
|
+
const d = startOfDay(now);
|
|
115
|
+
d.setHours(Number(hm[1]), Number(hm[2]), Number(hm[3] ?? 0), 0);
|
|
116
|
+
return d;
|
|
117
|
+
}
|
|
118
|
+
const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
119
|
+
if (ymd)
|
|
120
|
+
return new Date(Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3]));
|
|
121
|
+
const d = new Date(input);
|
|
122
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
123
|
+
}
|
|
124
|
+
export function slugify(text, max = 48) {
|
|
125
|
+
return text
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
128
|
+
.replace(/^-+|-+$/g, "")
|
|
129
|
+
.slice(0, max)
|
|
130
|
+
.replace(/-+$/g, "");
|
|
131
|
+
}
|
|
132
|
+
export function plural(n, one, many = `${one}s`) {
|
|
133
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
134
|
+
}
|
|
135
|
+
export function toCsv(rows, columns) {
|
|
136
|
+
const esc = (v) => {
|
|
137
|
+
const s = v === null || v === undefined ? "" : String(v);
|
|
138
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
139
|
+
};
|
|
140
|
+
return [columns.join(","), ...rows.map((r) => columns.map((c) => esc(r[c])).join(","))].join("\n");
|
|
141
|
+
}
|
|
142
|
+
export function priorityRank(p) {
|
|
143
|
+
return p === "high" ? 0 : p === "medium" ? 1 : p === "low" ? 2 : 3;
|
|
144
|
+
}
|
|
145
|
+
/** Simple line diff (LCS) → unified-style lines prefixed with " ", "-" or "+". */
|
|
146
|
+
export function diffLines(a, b) {
|
|
147
|
+
const n = a.length;
|
|
148
|
+
const m = b.length;
|
|
149
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
150
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
151
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
152
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const out = [];
|
|
156
|
+
let i = 0;
|
|
157
|
+
let j = 0;
|
|
158
|
+
while (i < n && j < m) {
|
|
159
|
+
if (a[i] === b[j]) {
|
|
160
|
+
out.push({ op: " ", text: a[i] });
|
|
161
|
+
i++;
|
|
162
|
+
j++;
|
|
163
|
+
}
|
|
164
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
165
|
+
out.push({ op: "-", text: a[i++] });
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
out.push({ op: "+", text: b[j++] });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
while (i < n)
|
|
172
|
+
out.push({ op: "-", text: a[i++] });
|
|
173
|
+
while (j < m)
|
|
174
|
+
out.push({ op: "+", text: b[j++] });
|
|
175
|
+
return out;
|
|
176
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scribeclock",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ScribeClock from the terminal: tasks, time clock, projects, PRDs, meetings, files, vault and Cloudflare — a thin client over the ScribeClock MCP server.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"author": "Haydar Al-Karar LLC, dba Karar Tech",
|
|
7
|
+
"homepage": "https://scribeclock.com/help/mcp-server",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/Scribe-Clock/ScribeClock.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"scribeclock": "./bin/scribeclock.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:integration": "SCRIBECLOCK_INTEGRATION=1 vitest run test/integration.test.ts",
|
|
30
|
+
"prepack": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.20.1",
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"vitest": "^3.2.7"
|
|
36
|
+
}
|
|
37
|
+
}
|