@workscribe/cli 1.6.2 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,7 @@ shell hook → capture → SQLite → session grouper → AI provider → markdo
29
29
 
30
30
  | Dependency | Version | Notes |
31
31
  |---|---|---|
32
- | Node.js | **22 or later** | Uses the built-in `node:sqlite` module — not available in Node 20 or earlier |
32
+ | Node.js | **22 or later** | Uses the built-in `node:sqlite` module introduced in Node 22.5 |
33
33
  | Shell | zsh or bash | Hook is appended to `~/.zshrc` or `~/.bashrc` by `workscribe init` |
34
34
  | OS | macOS or Linux | Windows is not supported — the shell hook requires zsh or bash. WSL2 works. |
35
35
 
@@ -47,6 +47,21 @@ source ~/.zshrc # or restart your terminal
47
47
 
48
48
  `workscribe init` walks you through choosing an AI provider, installs the shell hook, and offers to install a Claude Code session capture hook if Claude Code is detected. Safe to re-run — updates outdated hooks and skips steps already complete.
49
49
 
50
+ ## Uninstalling
51
+
52
+ If the CLI is still installed:
53
+
54
+ ```bash
55
+ workscribe uninstall # removes shell hooks, optionally deletes ~/.workscribe/
56
+ npm uninstall -g @workscribe/cli
57
+ ```
58
+
59
+ If you've already removed the binary and still see `_workscribe_precmd` errors in your terminal, remove the hook block manually:
60
+
61
+ 1. Open `~/.zshrc` (or `~/.bashrc`)
62
+ 2. Delete everything between `# workscribe:hook:start` and `# workscribe:hook:end` (inclusive)
63
+ 3. Run `source ~/.zshrc` or restart your terminal
64
+
50
65
  ## AI providers
51
66
 
52
67
  Workscribe works with any of these — switch at any time via config:
@@ -68,14 +83,22 @@ workscribe config set ai.apiKey <your-key>
68
83
 
69
84
  | Command | Description |
70
85
  |---|---|
86
+ | `workscribe init` | Set up Workscribe, install shell hooks, configure AI provider |
87
+ | `workscribe uninstall` | Remove shell hooks and optionally delete all local data |
71
88
  | `workscribe summary` | Generate today's work summary |
72
89
  | `workscribe summary --date 2026-05-20` | Summary for a specific date |
73
90
  | `workscribe summary --week` | Summary for the current week |
74
91
  | `workscribe summary --fresh` | Bypass cache and regenerate |
75
92
  | `workscribe summary --slack` | Post summary to configured Slack channel |
76
- | `workscribe sessions` | List detected work sessions |
77
- | `workscribe events` | List captured events |
93
+ | `workscribe summary --webhook <url>` | Post summary to a custom webhook URL |
94
+ | `workscribe sessions` | List detected work sessions for today |
95
+ | `workscribe sessions --date 2026-05-20` | Sessions for a specific date |
96
+ | `workscribe events` | List captured events for today |
97
+ | `workscribe events --date 2026-05-20` | Events for a specific date |
98
+ | `workscribe events --limit 20` | Show only the most recent N events |
78
99
  | `workscribe export` | Save today's summary as a markdown file |
100
+ | `workscribe export --date 2026-05-20` | Export summary for a specific date |
101
+ | `workscribe export --output <path>` | Write to a custom file path |
79
102
  | `workscribe status` | Check capture is active and show event counts |
80
103
  | `workscribe undo` | Remove the last captured event |
81
104
  | `workscribe undo -n 5` | Remove the last 5 captured events |
@@ -83,9 +106,11 @@ workscribe config set ai.apiKey <your-key>
83
106
  | `workscribe clear` | Delete all captured events for today |
84
107
  | `workscribe clear --date 2026-05-20` | Delete all events for a specific date |
85
108
  | `workscribe clear --dry-run` | Preview what would be cleared without deleting |
109
+ | `workscribe clear --yes` | Skip the confirmation prompt |
86
110
  | `workscribe pause` | Pause capture temporarily |
87
111
  | `workscribe resume` | Resume capture after a pause |
88
112
  | `workscribe config` | View current configuration |
113
+ | `workscribe config get KEY` | Print a single config value |
89
114
  | `workscribe config set KEY VALUE` | Update a config value |
90
115
 
91
116
  ## Privacy
@@ -102,16 +127,33 @@ workscribe config set ai.apiKey <your-key>
102
127
  workscribe config set session.idleTimeout 20 # minutes before a new session starts (default: 30)
103
128
  workscribe config set ai.model gpt-4o # override the default model for your provider
104
129
  workscribe config set redact.extra "MY_PATTERN" # add a custom redaction regex
130
+ workscribe config set summary.format json # output summary as JSON instead of markdown
131
+ workscribe config set capture.ignore "mycommand" # suppress a command from being captured
105
132
 
106
133
  # Override the display name for a local clone whose folder name differs from the repo
107
134
  workscribe config set projects./Users/me/sandbox/myrepo actual-repo-name
108
135
 
109
136
  # Post summaries to Slack or a custom webhook
110
137
  workscribe config set integrations.slackWebhook https://hooks.slack.com/services/...
138
+ workscribe config set integrations.webhook https://custom.endpoint/hook # persistent default webhook
111
139
  workscribe summary --slack
112
- workscribe summary --webhook https://custom.endpoint/hook
140
+ workscribe summary --webhook https://custom.endpoint/hook # one-off override
113
141
  ```
114
142
 
143
+ | Config key | Default | Description |
144
+ |---|---|---|
145
+ | `ai.provider` | — | AI provider: `anthropic`, `openai`, `ollama`, `openai-compatible` |
146
+ | `ai.apiKey` | — | API key for the configured provider |
147
+ | `ai.model` | provider default | Model name override |
148
+ | `ai.baseUrl` | — | Base URL for `openai-compatible` providers |
149
+ | `session.idleTimeout` | `30` | Minutes of inactivity before a new session starts |
150
+ | `capture.ignore` | common shell builtins | Commands to exclude from capture |
151
+ | `redact.extra` | `[]` | Additional regex patterns to redact from captured commands |
152
+ | `summary.format` | `markdown` | Output format: `markdown` or `json` |
153
+ | `integrations.slackWebhook` | — | Slack incoming webhook URL |
154
+ | `integrations.webhook` | — | Default webhook URL for `--webhook` |
155
+ | `projects.<path>` | — | Display name override for a repo by absolute path |
156
+
115
157
  ## Slack integration
116
158
 
117
159
  Post your daily or weekly summary directly to a Slack channel:
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ var import_commander = require("commander");
30
30
  var import_fs4 = __toESM(require("fs"));
31
31
 
32
32
  // src/db/index.ts
33
+ var import_node_sqlite = require("node:sqlite");
33
34
  var import_fs = __toESM(require("fs"));
34
35
 
35
36
  // src/utils/paths.ts
@@ -43,42 +44,11 @@ var EXPORTS_DIR = import_path.default.join(WORKSCRIBE_DIR, "exports");
43
44
  var PAUSE_FLAG_PATH = import_path.default.join(WORKSCRIBE_DIR, "paused");
44
45
  var WEEKLY_CACHE_DIR = import_path.default.join(WORKSCRIBE_DIR, "weekly-cache");
45
46
 
46
- // src/db/adapter.ts
47
- function openDatabase(path9) {
48
- try {
49
- const { DatabaseSync } = require(node:sqlite);
50
- return new DatabaseSync(path9);
51
- } catch {
52
- }
53
- try {
54
- const Database = require("better-sqlite3");
55
- return new Database(path9);
56
- } catch {
57
- }
58
- throw new Error(
59
- "workscribe requires a SQLite driver.\n Node 22.5+: node:sqlite is used automatically (no action needed).\n Node 20\u201321: install the optional peer dependency:\n npm install better-sqlite3"
60
- );
61
- }
62
-
63
- // src/db/index.ts
64
- var _db = null;
65
- function getDb() {
66
- if (_db) return _db;
67
- if (!import_fs.default.existsSync(WORKSCRIBE_DIR)) {
68
- import_fs.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true });
69
- }
70
- _db = openDatabase(DB_PATH);
71
- try {
72
- import_fs.default.chmodSync(DB_PATH, 384);
73
- } catch {
74
- }
75
- _db.exec("PRAGMA journal_mode = WAL");
76
- _db.exec("PRAGMA foreign_keys = ON");
77
- runMigrations(_db);
78
- return _db;
79
- }
80
- function runMigrations(db) {
81
- db.exec(`
47
+ // src/db/migrations/001_initial_schema.ts
48
+ var initial_schema_default = {
49
+ version: 1,
50
+ description: "initial schema",
51
+ sql: `
82
52
  CREATE TABLE IF NOT EXISTS events (
83
53
  id INTEGER PRIMARY KEY AUTOINCREMENT,
84
54
  captured_at TEXT NOT NULL,
@@ -112,7 +82,64 @@ function runMigrations(db) {
112
82
  event_id INTEGER NOT NULL REFERENCES events(id),
113
83
  PRIMARY KEY (session_id, event_id)
114
84
  );
85
+ `
86
+ };
87
+
88
+ // src/db/migrations/index.ts
89
+ var MIGRATIONS = [initial_schema_default];
90
+
91
+ // src/db/index.ts
92
+ var _db = null;
93
+ function getDb() {
94
+ if (_db) return _db;
95
+ if (!import_fs.default.existsSync(WORKSCRIBE_DIR)) {
96
+ import_fs.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true });
97
+ }
98
+ const db = new import_node_sqlite.DatabaseSync(DB_PATH);
99
+ try {
100
+ import_fs.default.chmodSync(DB_PATH, 384);
101
+ } catch {
102
+ }
103
+ db.exec("PRAGMA journal_mode = WAL");
104
+ db.exec("PRAGMA foreign_keys = ON");
105
+ applyMigrations(db);
106
+ _db = db;
107
+ return _db;
108
+ }
109
+ function applyMigrations(db) {
110
+ db.exec(`
111
+ CREATE TABLE IF NOT EXISTS schema_migrations (
112
+ version INTEGER PRIMARY KEY,
113
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
114
+ )
115
115
  `);
116
+ const hasEvents = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").get();
117
+ const hasVersion1 = db.prepare("SELECT version FROM schema_migrations WHERE version = 1").get();
118
+ if (hasEvents && !hasVersion1) {
119
+ db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES ($version, datetime('now'))").run({
120
+ version: 1
121
+ });
122
+ }
123
+ const applied = new Set(
124
+ db.prepare("SELECT version FROM schema_migrations").all().map((r) => r.version)
125
+ );
126
+ for (const migration of MIGRATIONS) {
127
+ if (applied.has(migration.version)) continue;
128
+ db.exec("BEGIN");
129
+ try {
130
+ db.exec(migration.sql);
131
+ db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES ($version, datetime('now'))").run({
132
+ version: migration.version
133
+ });
134
+ db.exec("COMMIT");
135
+ } catch (err) {
136
+ try {
137
+ db.exec("ROLLBACK");
138
+ } catch {
139
+ }
140
+ throw new Error(`Migration ${migration.version} (${migration.description}) failed: ${err.message}`);
141
+ }
142
+ }
116
143
  }
117
144
  function insertEvent(params) {
118
145
  const db = getDb();
@@ -131,7 +158,7 @@ function getEventsForDate(date) {
131
158
  const db = getDb();
132
159
  const stmt = db.prepare(`
133
160
  SELECT * FROM events
134
- WHERE date(captured_at) = $date
161
+ WHERE date(captured_at, 'localtime') = $date
135
162
  ORDER BY captured_at ASC
136
163
  `);
137
164
  return stmt.all({ date });
@@ -158,6 +185,16 @@ function insertSessionEvent(sessionId, eventId) {
158
185
  `);
159
186
  stmt.run({ session_id: sessionId, event_id: eventId });
160
187
  }
188
+ function getEventsBySessionId(sessionId) {
189
+ const db = getDb();
190
+ const stmt = db.prepare(`
191
+ SELECT e.* FROM events e
192
+ INNER JOIN session_events se ON se.event_id = e.id
193
+ WHERE se.session_id = $session_id
194
+ ORDER BY e.captured_at ASC
195
+ `);
196
+ return stmt.all({ session_id: sessionId });
197
+ }
161
198
  function getSessionsForDate(date) {
162
199
  const db = getDb();
163
200
  const stmt = db.prepare("SELECT * FROM sessions WHERE date = $date ORDER BY started_at ASC");
@@ -183,7 +220,7 @@ function deleteEventsByIds(ids) {
183
220
  if (ids.length === 0) return;
184
221
  const db = getDb();
185
222
  const placeholders = ids.map(() => "?").join(", ");
186
- const affectedDates = db.prepare(`SELECT DISTINCT date(captured_at) as date FROM events WHERE id IN (${placeholders})`).all(...ids);
223
+ const affectedDates = db.prepare(`SELECT DISTINCT date(captured_at, 'localtime') as date FROM events WHERE id IN (${placeholders})`).all(...ids);
187
224
  db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...ids);
188
225
  for (const { date } of affectedDates) {
189
226
  clearSessionsForDate(date);
@@ -683,6 +720,7 @@ function captureCommand(opts) {
683
720
  const nameOverride = config.projects?.[cwd] ?? null;
684
721
  const { repoName, branch } = getGitContext(cwd, nameOverride);
685
722
  const { category, metadata } = normalizeCommand(safeCommand);
723
+ if (opts.source) metadata.source = opts.source;
686
724
  if (category === "ai_session" && opts.gitBefore) {
687
725
  const commits = getCommitsSince(opts.gitBefore, cwd);
688
726
  if (commits.length > 0) metadata.commits = commits;
@@ -750,7 +788,7 @@ function parseHookPayload(raw) {
750
788
  return { command, cwd };
751
789
  }
752
790
  function registerHook(program2) {
753
- program2.command("_hook").description("Internal: capture events from AI tool hooks (stdin JSON payload)").option("--source <source>", "hook source identifier (e.g. claude-code)").action(() => {
791
+ program2.command("_hook").description("Internal: capture events from AI tool hooks (stdin JSON payload)").option("--source <source>", "hook source identifier (e.g. claude-code)").action((opts) => {
754
792
  let raw = "";
755
793
  process.stdin.setEncoding("utf-8");
756
794
  process.stdin.on("data", (chunk) => {
@@ -760,7 +798,13 @@ function registerHook(program2) {
760
798
  try {
761
799
  const parsed = parseHookPayload(raw);
762
800
  if (!parsed) return;
763
- captureCommand({ command: parsed.command, cwd: parsed.cwd, exitCode: null, durationMs: null });
801
+ captureCommand({
802
+ command: parsed.command,
803
+ cwd: parsed.cwd,
804
+ exitCode: null,
805
+ durationMs: null,
806
+ source: opts.source
807
+ });
764
808
  } catch (err) {
765
809
  debugLog("_hook", err);
766
810
  }
@@ -780,6 +824,7 @@ var import_fs6 = __toESM(require("fs"));
780
824
  var import_path3 = __toESM(require("path"));
781
825
  var HOOK_START = "# workscribe:hook:start";
782
826
  var HOOK_END = "# workscribe:hook:end";
827
+ var HOOK_PREAMBLE = "# Workscribe \u2014 developer work journal";
783
828
  function getInstalledHookBlock(rcFile) {
784
829
  if (!import_fs6.default.existsSync(rcFile)) return null;
785
830
  const content = import_fs6.default.readFileSync(rcFile, "utf-8");
@@ -820,7 +865,9 @@ function removeHook(rcFile) {
820
865
  const content = import_fs6.default.readFileSync(rcFile, "utf-8");
821
866
  const startIdx = content.indexOf(HOOK_START);
822
867
  const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
823
- const before = content.slice(0, startIdx).replace(/\n+$/, "\n");
868
+ const preambleIdx = content.lastIndexOf(HOOK_PREAMBLE, startIdx);
869
+ const blockStart = preambleIdx !== -1 ? preambleIdx : startIdx;
870
+ const before = content.slice(0, blockStart).replace(/\n+$/, "\n");
824
871
  const after = content.slice(endIdx).replace(/^\n/, "");
825
872
  import_fs6.default.writeFileSync(rcFile, before + after);
826
873
  }
@@ -1043,7 +1090,7 @@ function formatDate(isoString) {
1043
1090
  return new Date(isoString).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1044
1091
  }
1045
1092
  function todayDate() {
1046
- return (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1093
+ return localDateStr(/* @__PURE__ */ new Date());
1047
1094
  }
1048
1095
  function parseDate(input) {
1049
1096
  const d = /* @__PURE__ */ new Date(input + "T00:00:00");
@@ -1237,14 +1284,17 @@ function getOrBuildSessions(date) {
1237
1284
  durationMinutes: minutesBetween(s.started_at, s.ended_at),
1238
1285
  eventCount: s.event_count,
1239
1286
  summary: s.summary,
1240
- events: []
1241
- // not needed when using cached
1287
+ events: getEventsBySessionId(s.id)
1242
1288
  }));
1243
1289
  }
1290
+ const savedSummaries = /* @__PURE__ */ new Map();
1291
+ for (const s of existing) {
1292
+ if (s.summary) savedSummaries.set(`${s.repo_name}|${s.started_at}`, s.summary);
1293
+ }
1244
1294
  clearSessionsForDate(date);
1245
- return buildSessions(date, events);
1295
+ return buildSessions(date, events, savedSummaries);
1246
1296
  }
1247
- function buildSessions(date, events) {
1297
+ function buildSessions(date, events, savedSummaries = /* @__PURE__ */ new Map()) {
1248
1298
  const config = readConfig();
1249
1299
  const idleMs = config.session.idleTimeout * 60 * 1e3;
1250
1300
  const sessions = [];
@@ -1257,13 +1307,14 @@ function buildSessions(date, events) {
1257
1307
  const first = repoGroup[0];
1258
1308
  const last = repoGroup[repoGroup.length - 1];
1259
1309
  const repoName = last.repo_name ?? first.repo_name ?? null;
1310
+ const summary = savedSummaries.get(`${repoName}|${first.captured_at}`) ?? null;
1260
1311
  const sessionId = insertSession({
1261
1312
  date,
1262
1313
  started_at: first.captured_at,
1263
1314
  ended_at: last.captured_at,
1264
1315
  repo_name: repoName,
1265
1316
  event_count: repoGroup.length,
1266
- summary: null
1317
+ summary
1267
1318
  });
1268
1319
  for (const event of repoGroup) {
1269
1320
  insertSessionEvent(sessionId, event.id);
@@ -1276,7 +1327,7 @@ function buildSessions(date, events) {
1276
1327
  repoName,
1277
1328
  durationMinutes: minutesBetween(first.captured_at, last.captured_at),
1278
1329
  eventCount: repoGroup.length,
1279
- summary: null,
1330
+ summary,
1280
1331
  events: repoGroup
1281
1332
  });
1282
1333
  }
@@ -1940,7 +1991,12 @@ function registerConfig(program2) {
1940
1991
  if (value === void 0) {
1941
1992
  log.warn(`Key not found: ${key}`);
1942
1993
  } else {
1943
- log.plain(String(value));
1994
+ const lastSegment = key.split(".").pop() ?? "";
1995
+ if (SENSITIVE_KEYS.has(lastSegment) && typeof value === "string" && value.length > 0) {
1996
+ log.plain(`${value.slice(0, 4)}${"*".repeat(8)} (${value.length} chars)`);
1997
+ } else {
1998
+ log.plain(String(value));
1999
+ }
1944
2000
  }
1945
2001
  });
1946
2002
  }
@@ -2088,7 +2144,7 @@ function detectShellRcFiles() {
2088
2144
  return candidates.filter((c) => import_fs13.default.existsSync(c.rcFile) && isHookInstalled(c.rcFile));
2089
2145
  }
2090
2146
  function registerUninstall(program2) {
2091
- program2.command("uninstall").description("Remove shell hooks and optionally delete all Workscribe data").action(async () => {
2147
+ program2.command("uninstall").description("Remove shell hooks and optionally delete all Workscribe data").option("--hooks-only", "remove shell hooks only, skip data deletion prompt (used by npm preuninstall)").action(async (opts) => {
2092
2148
  log.plain(import_chalk9.default.bold("\n Workscribe \u2014 Uninstall\n"));
2093
2149
  const hooked = detectShellRcFiles();
2094
2150
  if (hooked.length === 0) {
@@ -2103,6 +2159,7 @@ function registerUninstall(program2) {
2103
2159
  );
2104
2160
  }
2105
2161
  }
2162
+ if (opts.hooksOnly) return;
2106
2163
  if (import_fs13.default.existsSync(WORKSCRIBE_DIR)) {
2107
2164
  log.plain("");
2108
2165
  const { deleteData } = await (0, import_prompts3.default)({
@@ -2126,7 +2183,7 @@ function registerUninstall(program2) {
2126
2183
 
2127
2184
  // src/index.ts
2128
2185
  var program = new import_commander.Command();
2129
- program.name("workscribe").description("Automatic developer work journal").version("1.6.2");
2186
+ program.name("workscribe").description("Automatic developer work journal").version("1.7.1");
2130
2187
  registerCapture(program);
2131
2188
  registerHook(program);
2132
2189
  registerInit(program);