codex-work-receipt 0.7.4 → 0.8.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/src/cli.mjs CHANGED
@@ -1,45 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from "node:child_process";
4
- import fs from "node:fs";
5
4
  import path from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
6
  import QRCode from "qrcode";
8
7
 
9
8
  import { parseArgs, printHelp } from "./core/args.mjs";
10
- import { buildCanonicalFacts } from "./core/fact-buckets.mjs";
11
- import { collectMetrics } from "./core/metrics.mjs";
9
+ import {
10
+ automaticOutputPath,
11
+ configureManualMode,
12
+ enableAutomaticMode,
13
+ getAutomaticStatus,
14
+ getWorkReceiptHome,
15
+ readAutoConfig,
16
+ startAutomaticRun,
17
+ } from "./core/auto-mode.mjs";
18
+ import { generateReceipt } from "./core/generator.mjs";
19
+ import { promptForGenerationMode } from "./core/mode-selector.mjs";
12
20
  import { printOpenSourcePrompt } from "./core/open-source.mjs";
13
21
  import { getRollingSummaryNotice, getScopeLabel } from "./core/presentation.mjs";
14
- import { encodeReceiptPayloads } from "./core/qr-payload.mjs";
15
- import { outputSlugForRange, resolveRange } from "./core/range.mjs";
16
- import { buildReceiptRecord, persistReceiptRecord } from "./core/receipt-record.mjs";
22
+ import { encodeSingleReceiptQr } from "./core/qr-payload.mjs";
17
23
  import { promptForRange } from "./core/selector.mjs";
18
24
  import { installCodexSkill } from "./core/skill-installer.mjs";
19
25
  import { installCodexPet, uninstallCodexPet } from "./core/pet-installer.mjs";
20
26
  import { formatNumber } from "./lib/time.mjs";
21
- import { listRecentCodexSessions, loadCodexSessions } from "./parsers/codex.mjs";
22
- import { renderHtml } from "./renderers/html.mjs";
27
+ import { listRecentCodexSessions } from "./parsers/codex.mjs";
23
28
 
24
29
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
25
30
  const PROJECT_DIR = path.dirname(SCRIPT_DIR);
26
- const DEFAULT_OUTPUT_DIR = path.join(process.cwd(), "codex-work-receipt-output");
27
- const DEFAULT_MINIPROGRAM_CODE = path.join(PROJECT_DIR, "assets", "miniprogram-code.png");
28
-
29
- function mimeTypeForImage(filePath) {
30
- const extension = path.extname(filePath).toLowerCase();
31
- if (extension === ".png") return "image/png";
32
- if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
33
- if (extension === ".webp") return "image/webp";
34
- if (extension === ".svg") return "image/svg+xml";
35
- throw new Error(`不支持的小程序码图片格式:${extension || "未知"}`);
36
- }
37
-
38
- function imageAsDataUrl(filePath) {
39
- if (!filePath || !fs.existsSync(filePath)) return null;
40
- return `data:${mimeTypeForImage(filePath)};base64,${fs.readFileSync(filePath).toString("base64")}`;
41
- }
42
-
43
31
  function openFile(filePath) {
44
32
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
45
33
  const args = process.platform === "win32" ? ["/c", "start", "", filePath] : [filePath];
@@ -47,12 +35,114 @@ function openFile(filePath) {
47
35
  child.unref();
48
36
  }
49
37
 
38
+ function isInteractive() {
39
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
40
+ }
41
+
42
+ function printAutomaticEnabled(result, locale) {
43
+ const outputFile = automaticOutputPath(result.config);
44
+ const importFile = outputFile.replace(/\.html?$/i, ".cwr.json");
45
+ if (locale === "en") {
46
+ console.log("Automatic saving enabled.");
47
+ console.log("Codex will quietly refresh today's receipt and WeChat import file whenever a turn stops.");
48
+ console.log(`Automatic receipt: ${outputFile}`);
49
+ console.log(`WeChat import file: ${importFile}`);
50
+ console.log("Restart Codex. If Codex asks you to review the new hook, open /hooks and trust AI Work Receipt.");
51
+ } else {
52
+ console.log("自动保存已启用。");
53
+ console.log("Codex 每完成一轮工作,都会静默刷新今天的小票和微信导入文件。");
54
+ console.log(`自动小票:${outputFile}`);
55
+ console.log(`微信导入文件:${importFile}`);
56
+ console.log("请重启 Codex;如果 Codex 提示审查新 Hook,请打开 /hooks 并信任 AI 打工小票。");
57
+ }
58
+ }
59
+
60
+ function printManualEnabled(result, locale) {
61
+ if (locale === "en") {
62
+ console.log("Manual-only mode enabled. No AI Work Receipt hook will run in the background.");
63
+ if (result.removedHook?.removed) console.log("The AI Work Receipt hook was removed; existing receipts were kept.");
64
+ } else {
65
+ console.log("已切换为仅手动模式,AI 打工小票不会在后台自动运行。");
66
+ if (result.removedHook?.removed) console.log("已移除 AI 打工小票 Hook,历史小票仍然保留。");
67
+ }
68
+ }
69
+
70
+ function enableAuto(options) {
71
+ const result = enableAutomaticMode({
72
+ projectDir: PROJECT_DIR,
73
+ dataDir: options.dataDir,
74
+ locale: options.locale,
75
+ timezone: options.timezone,
76
+ theme: options.theme,
77
+ });
78
+ startAutomaticRun(result.config);
79
+ printAutomaticEnabled(result, options.locale);
80
+ printOpenSourcePrompt("receipt", options.locale);
81
+ return result;
82
+ }
83
+
84
+ function enableManual(options, { showOpenSourcePrompt = true } = {}) {
85
+ const result = configureManualMode({
86
+ dataDir: options.dataDir,
87
+ locale: options.locale,
88
+ timezone: options.timezone,
89
+ theme: options.theme,
90
+ });
91
+ printManualEnabled(result, options.locale);
92
+ if (showOpenSourcePrompt) printOpenSourcePrompt("receipt", options.locale);
93
+ return result;
94
+ }
95
+
96
+ function printAutoStatus(options) {
97
+ const status = getAutomaticStatus({ dataDir: options.dataDir });
98
+ if (options.locale === "en") {
99
+ console.log(`Mode: ${status.mode}`);
100
+ console.log(`Local data: ${status.workReceiptHome}`);
101
+ console.log(`Runtime: ${status.runtimeInstalled ? "installed" : "not installed"}`);
102
+ console.log(`Codex hook: ${status.hookInstalled ? "installed" : "not installed"}`);
103
+ if (status.state?.last_success_at) console.log(`Last automatic receipt: ${status.state.last_success_at}`);
104
+ if (status.state?.import_file) console.log(`WeChat import file: ${status.state.import_file}`);
105
+ if (status.state?.error) console.log(`Last message: ${status.state.error}`);
106
+ } else {
107
+ const modeLabel = status.mode === "automatic" ? "自动保存" : status.mode === "manual" ? "仅手动" : "尚未设置";
108
+ console.log(`模式:${modeLabel}`);
109
+ console.log(`本地数据:${status.workReceiptHome}`);
110
+ console.log(`本地执行器:${status.runtimeInstalled ? "已安装" : "未安装"}`);
111
+ console.log(`Codex Hook:${status.hookInstalled ? "已安装" : "未安装"}`);
112
+ if (status.state?.last_success_at) console.log(`最近自动生成:${status.state.last_success_at}`);
113
+ if (status.state?.import_file) console.log(`微信导入文件:${status.state.import_file}`);
114
+ if (status.state?.error) console.log(`最近状态:${status.state.error}`);
115
+ }
116
+ }
117
+
118
+ async function runSetup(options) {
119
+ if (!isInteractive()) throw new Error("--setup 需要在交互式终端中运行;也可以使用 --enable-auto 或 --disable-auto");
120
+ const selected = await promptForGenerationMode({ locale: options.locale });
121
+ return selected === "automatic" ? enableAuto(options) : enableManual(options);
122
+ }
123
+
50
124
  async function main() {
51
125
  const options = parseArgs(process.argv.slice(2));
52
126
  if (options.help) {
53
127
  printHelp(options.locale);
54
128
  return;
55
129
  }
130
+ if (options.setup) {
131
+ await runSetup(options);
132
+ return;
133
+ }
134
+ if (options.enableAuto) {
135
+ enableAuto(options);
136
+ return;
137
+ }
138
+ if (options.disableAuto) {
139
+ enableManual(options);
140
+ return;
141
+ }
142
+ if (options.autoStatus) {
143
+ printAutoStatus(options);
144
+ return;
145
+ }
56
146
  if (options.installSkill) {
57
147
  const installed = installCodexSkill({ projectDir: PROJECT_DIR });
58
148
  if (options.locale === "en") {
@@ -95,7 +185,16 @@ async function main() {
95
185
  return;
96
186
  }
97
187
 
98
- if (!options.modeExplicit && process.stdin.isTTY && process.stdout.isTTY) {
188
+ if (!options.modeExplicit && isInteractive()) {
189
+ const workReceiptHome = getWorkReceiptHome({ dataDir: options.dataDir });
190
+ if (!readAutoConfig({ workReceiptHome })) {
191
+ const selectedMode = await promptForGenerationMode({ locale: options.locale });
192
+ if (selectedMode === "automatic") {
193
+ enableAuto(options);
194
+ return;
195
+ }
196
+ enableManual(options, { showOpenSourcePrompt: false });
197
+ }
99
198
  const selected = await promptForRange({
100
199
  locale: options.locale,
101
200
  timezone: options.timezone,
@@ -106,47 +205,44 @@ async function main() {
106
205
  options.hours = selected.hours || options.hours;
107
206
  }
108
207
 
109
- const range = resolveRange(options.mode, options.timezone, new Date(), options.sessionId, options.hours);
110
- const sessions = loadCodexSessions(range);
111
- const metrics = collectMetrics(sessions, range);
112
- const observedAt = new Date().toISOString();
113
- const canonical = range.scope === "last-hours"
114
- ? {}
115
- : buildCanonicalFacts(sessions, range, { observedAt });
116
- const record = buildReceiptRecord(metrics, options.theme, options.locale, canonical);
117
- const qrPayloads = encodeReceiptPayloads(record);
118
- const dataQrDataUrls = await Promise.all(qrPayloads.map((payload) => QRCode.toDataURL(payload, {
119
- errorCorrectionLevel: "M",
120
- margin: 2,
121
- width: 360,
122
- color: { dark: "#171713", light: "#ffffff" },
123
- })));
124
-
125
- const requestedOutput = options.output || path.join(
126
- DEFAULT_OUTPUT_DIR,
127
- `codex-receipt-${outputSlugForRange(range, record.id)}.html`,
128
- );
129
- const outputFile = path.resolve(/\.html?$/i.test(requestedOutput) ? requestedOutput : `${requestedOutput}.html`);
130
- fs.mkdirSync(path.dirname(outputFile), { recursive: true });
131
-
132
- const miniProgramCodeDataUrl = imageAsDataUrl(DEFAULT_MINIPROGRAM_CODE);
133
-
134
- fs.writeFileSync(
208
+ const generated = await generateReceipt(options, {
209
+ projectDir: PROJECT_DIR,
210
+ createDataQr: async (record) => {
211
+ const dataQr = encodeSingleReceiptQr(record);
212
+ if (!dataQr) return null;
213
+ return {
214
+ version: dataQr.version,
215
+ dataUrl: await QRCode.toDataURL(dataQr.payload, {
216
+ errorCorrectionLevel: "M",
217
+ margin: 2,
218
+ width: 360,
219
+ color: { dark: "#171713", light: "#ffffff" },
220
+ }),
221
+ };
222
+ },
223
+ });
224
+ const {
225
+ record,
135
226
  outputFile,
136
- renderHtml({ record, dataQrDataUrls, miniProgramCodeDataUrl }),
137
- "utf8",
138
- );
139
- const persisted = persistReceiptRecord(record, outputFile, options.dataDir);
227
+ persisted,
228
+ dataQrDataUrl,
229
+ dataQrVersion,
230
+ miniProgramCodeDataUrl,
231
+ } = generated;
140
232
 
141
233
  if (options.locale === "en") {
142
234
  console.log(`Generated HTML: ${outputFile}`);
143
235
  console.log(`Structured data: ${persisted.companionPath}`);
236
+ console.log(`WeChat import file: ${persisted.transferPath}`);
144
237
  console.log(`Local history: ${persisted.receiptPath}`);
145
238
  console.log(`Range: ${getScopeLabel(record.source.scope, options.locale, record.source.hours)} · ${record.stats.session_count} session(s)`);
146
239
  console.log(`Stats: ${record.stats.completed_turns} turns · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Tokens · ${record.stats.tool_calls} tool calls`);
240
+ console.log(dataQrDataUrl
241
+ ? `Data QR: available as one code · QR version ${dataQrVersion}`
242
+ : "Data QR: not generated because the payload exceeds one code; use the WeChat import file");
147
243
  console.log(record.manifest
148
- ? `Data QR: ${qrPayloads.length} code(s) · ${record.manifest.fact_count} canonical fact(s) · schema v${record.schema_version}`
149
- : `Data QR: ${qrPayloads.length} code(s) · rolling summary · schema v${record.schema_version}`);
244
+ ? `Import data: ${record.manifest.fact_count} canonical fact(s) · schema v${record.schema_version}`
245
+ : `Import data: rolling summary · schema v${record.schema_version}`);
150
246
  if (record.source.scope === "last-hours") {
151
247
  console.log(`Note: ${getRollingSummaryNotice(options.locale, record.source.hours)}`);
152
248
  }
@@ -154,12 +250,16 @@ async function main() {
154
250
  } else {
155
251
  console.log(`已生成网页:${outputFile}`);
156
252
  console.log(`结构数据:${persisted.companionPath}`);
253
+ console.log(`微信导入文件:${persisted.transferPath}`);
157
254
  console.log(`本地历史:${persisted.receiptPath}`);
158
255
  console.log(`统计范围:${getScopeLabel(record.source.scope, options.locale, record.source.hours)} · ${record.stats.session_count} 个会话`);
159
256
  console.log(`统计:${record.stats.completed_turns} 轮 · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Token · ${record.stats.tool_calls} 次工具调用`);
257
+ console.log(dataQrDataUrl
258
+ ? `数据二维码:可用 · 单码 · QR version ${dataQrVersion}`
259
+ : "数据二维码:数据超过单码容量,已改用微信聊天文件导入");
160
260
  console.log(record.manifest
161
- ? `数据二维码:${qrPayloads.length} 个 · ${record.manifest.fact_count} 条规范事实 · schema v${record.schema_version}`
162
- : `数据二维码:${qrPayloads.length} · 滚动摘要 · schema v${record.schema_version}`);
261
+ ? `导入数据:${record.manifest.fact_count} 条规范事实 · schema v${record.schema_version}`
262
+ : `导入数据:滚动摘要 · schema v${record.schema_version}`);
163
263
  if (record.source.scope === "last-hours") {
164
264
  console.log(`提示:${getRollingSummaryNotice(options.locale, record.source.hours)}`);
165
265
  }
package/src/core/args.mjs CHANGED
@@ -13,6 +13,7 @@ Usage:
13
13
  npx codex-work-receipt@latest --range this-week --lang en
14
14
  npx codex-work-receipt@latest --install-skill --lang en
15
15
  npx codex-work-receipt@latest --install-companion --lang en
16
+ npx codex-work-receipt@latest --setup --lang en
16
17
 
17
18
  Options:
18
19
  --range <name> Range: latest, last-hours, today, last-7-days, this-week
@@ -29,6 +30,10 @@ Options:
29
30
  --install-pet Install the Codex pet only
30
31
  --uninstall-pet Remove the installed Codex pet
31
32
  --install-companion Install both the skill and Codex pet
33
+ --setup Choose automatic saving or manual-only mode
34
+ --enable-auto Enable automatic daily receipt saving
35
+ --disable-auto Switch to manual-only mode
36
+ --auto-status Show automatic saving status
32
37
  --no-open Do not open the browser after generation
33
38
  --help Show help
34
39
  ` : `
@@ -43,6 +48,7 @@ Codex AI 打工小票
43
48
  npx codex-work-receipt@latest --range this-week
44
49
  npx codex-work-receipt@latest --install-skill
45
50
  npx codex-work-receipt@latest --install-companion
51
+ npx codex-work-receipt@latest --setup
46
52
 
47
53
  选项:
48
54
  --range <name> 统计范围:latest、last-hours、today、last-7-days、this-week
@@ -59,6 +65,10 @@ Codex AI 打工小票
59
65
  --install-pet 只安装 Codex 桌宠
60
66
  --uninstall-pet 卸载 AI 打工小票 Codex 桌宠
61
67
  --install-companion 同时安装 Skill 和 Codex 桌宠
68
+ --setup 选择自动保存或仅手动模式
69
+ --enable-auto 启用自动保存今日小票
70
+ --disable-auto 切换为仅手动模式
71
+ --auto-status 查看自动保存状态
62
72
  --no-open 生成后不自动打开浏览器
63
73
  --help 显示帮助
64
74
  `);
@@ -79,6 +89,10 @@ export function parseArgs(argv) {
79
89
  installPet: false,
80
90
  uninstallPet: false,
81
91
  installCompanion: false,
92
+ setup: false,
93
+ enableAuto: false,
94
+ disableAuto: false,
95
+ autoStatus: false,
82
96
  open: true,
83
97
  };
84
98
 
@@ -111,6 +125,10 @@ export function parseArgs(argv) {
111
125
  result.mode = scope;
112
126
  result.modeExplicit = true;
113
127
  } else if (argument === "--install-skill") result.installSkill = true;
128
+ else if (argument === "--setup") result.setup = true;
129
+ else if (argument === "--enable-auto") result.enableAuto = true;
130
+ else if (argument === "--disable-auto") result.disableAuto = true;
131
+ else if (argument === "--auto-status") result.autoStatus = true;
114
132
  else if (argument === "--no-open") result.open = false;
115
133
  else if (argument === "--help" || argument === "-h") result.help = true;
116
134
  else if (optionsWithValues.has(argument)) {
@@ -134,6 +152,11 @@ export function parseArgs(argv) {
134
152
  if (!new Set(["zh-CN", "en"]).has(result.locale)) {
135
153
  throw new Error(`不支持的语言:${result.locale}`);
136
154
  }
155
+ try {
156
+ new Intl.DateTimeFormat("en-US", { timeZone: result.timezone }).format(new Date());
157
+ } catch {
158
+ throw new Error(`不支持的时区:${result.timezone}`);
159
+ }
137
160
  if (result.hours !== null) {
138
161
  result.hours = Number(result.hours);
139
162
  if (!Number.isInteger(result.hours) || result.hours < 1 || result.hours > 168) {
@@ -141,5 +164,16 @@ export function parseArgs(argv) {
141
164
  }
142
165
  }
143
166
  if (result.mode === "last-hours" && result.hours === null) result.hours = 3;
167
+ const managementActions = [
168
+ result.setup,
169
+ result.enableAuto,
170
+ result.disableAuto,
171
+ result.autoStatus,
172
+ ].filter(Boolean).length;
173
+ if (managementActions > 1) throw new Error("自动保存管理参数不能同时使用");
174
+ if (managementActions && result.modeExplicit) throw new Error("自动保存管理参数不能与统计范围参数同时使用");
175
+ if (managementActions && (
176
+ result.installSkill || result.installPet || result.uninstallPet || result.installCompanion
177
+ )) throw new Error("自动保存管理参数不能与 Skill 或桌宠管理参数同时使用");
144
178
  return result;
145
179
  }