ccbot 1.0.0 → 1.2.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.
Files changed (44) hide show
  1. package/dist/commands/help.js +10 -9
  2. package/dist/commands/setup.js +41 -23
  3. package/dist/commands/uninstall.js +12 -10
  4. package/dist/commands/update.js +25 -23
  5. package/dist/config-manager.d.ts +2 -0
  6. package/dist/config-manager.js +14 -8
  7. package/dist/hook/hook-handler.d.ts +8 -3
  8. package/dist/hook/hook-handler.js +53 -18
  9. package/dist/hook/hook-installer.d.ts +1 -0
  10. package/dist/hook/hook-installer.js +19 -8
  11. package/dist/hook/hook-server.d.ts +2 -1
  12. package/dist/hook/hook-server.js +30 -6
  13. package/dist/hook/response-store.d.ts +8 -0
  14. package/dist/hook/response-store.js +18 -0
  15. package/dist/i18n/index.d.ts +9 -0
  16. package/dist/i18n/index.js +54 -0
  17. package/dist/i18n/locales/en.d.ts +2 -0
  18. package/dist/i18n/locales/en.js +121 -0
  19. package/dist/i18n/locales/vi.d.ts +2 -0
  20. package/dist/i18n/locales/vi.js +121 -0
  21. package/dist/i18n/locales/zh.d.ts +2 -0
  22. package/dist/i18n/locales/zh.js +121 -0
  23. package/dist/i18n/types.d.ts +121 -0
  24. package/dist/i18n/types.js +1 -0
  25. package/dist/index.js +38 -16
  26. package/dist/monitor/transcript-parser.d.ts +4 -0
  27. package/dist/monitor/transcript-parser.js +29 -3
  28. package/dist/telegram/bot.d.ts +8 -3
  29. package/dist/telegram/bot.js +69 -18
  30. package/dist/telegram/message-formatter.d.ts +7 -2
  31. package/dist/telegram/message-formatter.js +26 -23
  32. package/dist/telegram/message-sender.d.ts +1 -1
  33. package/dist/telegram/message-sender.js +37 -5
  34. package/dist/utils/constants.d.ts +23 -0
  35. package/dist/utils/constants.js +20 -0
  36. package/dist/utils/install-detection.d.ts +2 -1
  37. package/dist/utils/install-detection.js +6 -5
  38. package/dist/utils/log.d.ts +2 -0
  39. package/dist/utils/log.js +9 -0
  40. package/dist/utils/response-store.d.ts +27 -0
  41. package/dist/utils/response-store.js +84 -0
  42. package/dist/utils/tunnel.d.ts +7 -0
  43. package/dist/utils/tunnel.js +51 -0
  44. package/package.json +2 -1
@@ -1,17 +1,18 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { detectCliPrefix } from "../utils/install-detection.js";
3
+ import { t } from "../i18n/index.js";
3
4
  export function runHelp() {
4
5
  const prefix = detectCliPrefix();
5
- p.intro("🤖 ccbot — Claude Code ↔ Telegram Notification Bot");
6
+ p.intro(t("help.intro"));
6
7
  p.log.message([
7
- `Usage: ${prefix} [command]`,
8
+ t("help.usage", { prefix }),
8
9
  "",
9
- "Commands:",
10
- " (none) Start the bot",
11
- " setup Interactive setup (config + hooks)",
12
- " update Update ccbot to latest version",
13
- " uninstall Remove all ccbot data and hooks",
14
- " help Show this help message",
10
+ t("help.commands"),
11
+ t("help.cmdNone"),
12
+ t("help.cmdSetup"),
13
+ t("help.cmdUpdate"),
14
+ t("help.cmdUninstall"),
15
+ t("help.cmdHelp"),
15
16
  ].join("\n"));
16
- p.outro("docs → https://github.com/palooza-kaida/ccbot");
17
+ p.outro(t("help.docs"));
17
18
  }
@@ -6,48 +6,66 @@ import { ConfigManager } from "../config-manager.js";
6
6
  import { HookInstaller } from "../hook/hook-installer.js";
7
7
  import { detectCliPrefix } from "../utils/install-detection.js";
8
8
  import { formatError } from "../utils/error-utils.js";
9
+ import { t, setLocale, SUPPORTED_LOCALES, LOCALE_LABELS } from "../i18n/index.js";
9
10
  export async function runSetup() {
10
- p.intro("🤖 ccbot setup");
11
+ p.intro(t("setup.intro"));
11
12
  let existing = null;
12
13
  try {
13
14
  existing = ConfigManager.load();
14
15
  }
15
16
  catch { }
17
+ const locale = await promptLanguage(existing);
18
+ setLocale(locale);
16
19
  const credentials = await promptCredentials(existing);
17
- const config = buildConfig(credentials, existing);
20
+ const config = buildConfig(credentials, existing, locale);
18
21
  saveConfig(config);
19
22
  installHook(config);
20
23
  registerChatId(config.user_id);
21
24
  const startCommand = detectCliPrefix();
22
- p.outro(`🎉 Setup complete!\n\n Next steps:\n 1. Start bot: ${startCommand}\n 2. Use Claude Code normally → notifications will arrive`);
25
+ p.outro(t("setup.complete", { command: startCommand }));
26
+ }
27
+ async function promptLanguage(existing) {
28
+ const result = await p.select({
29
+ message: t("setup.languageMessage"),
30
+ initialValue: existing?.locale ?? "en",
31
+ options: SUPPORTED_LOCALES.map((loc) => ({
32
+ value: loc,
33
+ label: LOCALE_LABELS[loc],
34
+ })),
35
+ });
36
+ if (p.isCancel(result)) {
37
+ p.cancel(t("setup.cancelled"));
38
+ process.exit(0);
39
+ }
40
+ return result;
23
41
  }
24
42
  async function promptCredentials(existing) {
25
43
  const result = await p.group({
26
44
  token: () => p.text({
27
- message: "Telegram Bot Token",
28
- placeholder: "Get from @BotFather → /newbot",
45
+ message: t("setup.tokenMessage"),
46
+ placeholder: t("setup.tokenPlaceholder"),
29
47
  initialValue: existing?.telegram_bot_token ?? "",
30
48
  validate(value) {
31
49
  if (!value || !value.trim())
32
- return "Bot token is required";
50
+ return t("setup.tokenRequired");
33
51
  if (!value.includes(":"))
34
- return "Invalid format (expected: 123456:ABC-xxx)";
52
+ return t("setup.tokenInvalidFormat");
35
53
  },
36
54
  }),
37
55
  userId: () => p.text({
38
- message: "Your Telegram User ID",
39
- placeholder: "Send /start to @userinfobot",
56
+ message: t("setup.userIdMessage"),
57
+ placeholder: t("setup.userIdPlaceholder"),
40
58
  initialValue: existing?.user_id?.toString() ?? "",
41
59
  validate(value) {
42
60
  if (!value || !value.trim())
43
- return "User ID is required";
61
+ return t("setup.userIdRequired");
44
62
  if (isNaN(parseInt(value, 10)))
45
- return "Must be a number";
63
+ return t("setup.userIdMustBeNumber");
46
64
  },
47
65
  }),
48
66
  }, {
49
67
  onCancel: () => {
50
- p.cancel("Setup cancelled.");
68
+ p.cancel(t("setup.cancelled"));
51
69
  process.exit(0);
52
70
  },
53
71
  });
@@ -56,32 +74,32 @@ async function promptCredentials(existing) {
56
74
  userId: parseInt(result.userId.trim(), 10),
57
75
  };
58
76
  }
59
- function buildConfig(credentials, existing) {
77
+ function buildConfig(credentials, existing, locale) {
60
78
  return {
61
79
  telegram_bot_token: credentials.token,
62
80
  user_id: credentials.userId,
63
81
  hook_port: existing?.hook_port || 9377,
64
82
  hook_secret: existing?.hook_secret || ConfigManager.generateSecret(),
83
+ locale,
65
84
  };
66
85
  }
67
86
  function saveConfig(config) {
68
87
  ConfigManager.save(config);
69
- p.log.success("Config saved");
88
+ p.log.success(t("setup.configSaved"));
70
89
  }
71
90
  function installHook(config) {
91
+ if (HookInstaller.isInstalled()) {
92
+ p.log.step(t("setup.hookAlreadyInstalled"));
93
+ return;
94
+ }
72
95
  try {
73
96
  HookInstaller.install(config.hook_port, config.hook_secret);
74
- p.log.success("Hook installed → ~/.claude/settings.json");
97
+ p.log.success(t("setup.hookInstalled"));
75
98
  }
76
99
  catch (err) {
77
100
  const msg = formatError(err);
78
- if (msg.includes("already installed")) {
79
- p.log.step("Hook already installed");
80
- }
81
- else {
82
- p.log.error(`Hook installation failed: ${msg}`);
83
- throw new Error(`install hook: ${msg}`);
84
- }
101
+ p.log.error(t("setup.hookFailed", { error: msg }));
102
+ throw new Error(`install hook: ${msg}`);
85
103
  }
86
104
  }
87
105
  function registerChatId(userId) {
@@ -99,5 +117,5 @@ function registerChatId(userId) {
99
117
  state.chat_id = userId;
100
118
  mkdirSync(stateDir, { recursive: true });
101
119
  writeFileSync(stateFile, JSON.stringify(state, null, 2), { mode: 0o600 });
102
- p.log.success("Chat ID registered");
120
+ p.log.success(t("setup.chatIdRegistered"));
103
121
  }
@@ -4,38 +4,40 @@ import { join } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { HookInstaller } from "../hook/hook-installer.js";
6
6
  import { detectInstallMethod } from "../utils/install-detection.js";
7
+ import { InstallMethod } from "../utils/constants.js";
8
+ import { t } from "../i18n/index.js";
7
9
  export function runUninstall() {
8
- p.intro("🗑️ Uninstalling ccbot");
10
+ p.intro(t("uninstall.intro"));
9
11
  removeHook();
10
12
  removeConfigDirectory();
11
13
  printPostUninstallHint();
12
- p.outro("ccbot uninstalled");
14
+ p.outro(t("uninstall.done"));
13
15
  }
14
16
  function removeHook() {
15
17
  try {
16
18
  HookInstaller.uninstall();
17
- p.log.success("Hook removed from ~/.claude/settings.json");
19
+ p.log.success(t("uninstall.hookRemoved"));
18
20
  }
19
21
  catch {
20
- p.log.warn("No hook found (already removed)");
22
+ p.log.warn(t("uninstall.hookNotFound"));
21
23
  }
22
24
  }
23
25
  function removeConfigDirectory() {
24
26
  const ccbotDir = join(homedir(), ".ccbot");
25
27
  try {
26
28
  rmSync(ccbotDir, { recursive: true, force: true });
27
- p.log.success("Removed ~/.ccbot/ (config, state, hooks)");
29
+ p.log.success(t("uninstall.configRemoved"));
28
30
  }
29
31
  catch {
30
- p.log.warn("~/.ccbot/ not found (already removed)");
32
+ p.log.warn(t("uninstall.configNotFound"));
31
33
  }
32
34
  }
33
35
  function printPostUninstallHint() {
34
36
  const method = detectInstallMethod();
35
- if (method === "global") {
36
- p.log.info("To also remove the package:\n pnpm remove -g ccbot");
37
+ if (method === InstallMethod.Global) {
38
+ p.log.info(t("uninstall.removeGlobal"));
37
39
  }
38
- else if (method === "git-clone") {
39
- p.log.info("To also remove the source:\n rm -rf <ccbot-directory>");
40
+ else if (method === InstallMethod.GitClone) {
41
+ p.log.info(t("uninstall.removeGitClone"));
40
42
  }
41
43
  }
@@ -3,18 +3,20 @@ import { execSync } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { detectInstallMethod, getGitRepoRoot } from "../utils/install-detection.js";
6
+ import { InstallMethod } from "../utils/constants.js";
7
+ import { t } from "../i18n/index.js";
6
8
  export function runUpdate() {
7
9
  const method = detectInstallMethod();
8
10
  switch (method) {
9
- case "npx":
10
- p.intro("📦 ccbot update");
11
- p.log.step("Installed via npx — always uses latest version, no update needed.");
12
- p.outro("Already up to date");
11
+ case InstallMethod.Npx:
12
+ p.intro(t("update.intro"));
13
+ p.log.step(t("update.npxAlreadyLatest"));
14
+ p.outro(t("update.npxDone"));
13
15
  break;
14
- case "global":
16
+ case InstallMethod.Global:
15
17
  updateGlobal();
16
18
  break;
17
- case "git-clone":
19
+ case InstallMethod.GitClone:
18
20
  updateGitClone();
19
21
  break;
20
22
  }
@@ -32,20 +34,20 @@ function detectGlobalPackageManager() {
32
34
  function updateGlobal() {
33
35
  const pm = detectGlobalPackageManager();
34
36
  const pkg = "ccbot";
35
- p.intro("📦 ccbot update");
37
+ p.intro(t("update.intro"));
36
38
  const s = p.spinner();
37
- s.start(`Updating via ${pm}...`);
39
+ s.start(t("update.updating", { pm }));
38
40
  const cmd = pm === "yarn"
39
41
  ? `yarn global add ${pkg}`
40
42
  : `${pm} install -g ${pkg}@latest`;
41
43
  try {
42
44
  execSync(cmd, { stdio: "pipe" });
43
- s.stop("Updated successfully");
44
- p.outro("Update complete");
45
+ s.stop(t("update.updateSuccess"));
46
+ p.outro(t("update.updateComplete"));
45
47
  }
46
48
  catch {
47
- s.stop("Update failed");
48
- p.log.error(`Try manually: ${cmd}`);
49
+ s.stop(t("update.updateFailed"));
50
+ p.log.error(t("update.updateManualGlobal", { cmd }));
49
51
  process.exit(1);
50
52
  }
51
53
  }
@@ -53,15 +55,15 @@ function updateGitClone() {
53
55
  const scriptDir = dirname(process.argv[1] ?? "");
54
56
  const repoRoot = getGitRepoRoot(scriptDir);
55
57
  if (!repoRoot) {
56
- p.log.error("Could not find git repo root.");
58
+ p.log.error(t("update.gitRepoNotFound"));
57
59
  process.exit(1);
58
60
  }
59
- p.intro("📦 ccbot update");
61
+ p.intro(t("update.intro"));
60
62
  const s = p.spinner();
61
63
  try {
62
- s.start("Pulling latest changes...");
64
+ s.start(t("update.pulling"));
63
65
  execSync("git pull", { cwd: repoRoot, stdio: "pipe" });
64
- s.stop("Pulled latest changes");
66
+ s.stop(t("update.pulled"));
65
67
  const pm = existsSync(join(repoRoot, "pnpm-lock.yaml"))
66
68
  ? "pnpm"
67
69
  : existsSync(join(repoRoot, "yarn.lock"))
@@ -69,17 +71,17 @@ function updateGitClone() {
69
71
  : existsSync(join(repoRoot, "bun.lockb"))
70
72
  ? "bun"
71
73
  : "npm";
72
- s.start("Installing dependencies...");
74
+ s.start(t("update.installingDeps"));
73
75
  execSync(`${pm} install`, { cwd: repoRoot, stdio: "pipe" });
74
- s.stop("Dependencies installed");
75
- s.start("Building...");
76
+ s.stop(t("update.depsInstalled"));
77
+ s.start(t("update.building"));
76
78
  execSync(`${pm} run build`, { cwd: repoRoot, stdio: "pipe" });
77
- s.stop("Build complete");
78
- p.outro("Update complete");
79
+ s.stop(t("update.buildComplete"));
80
+ p.outro(t("update.updateComplete"));
79
81
  }
80
82
  catch {
81
- s.stop("Update failed");
82
- p.log.error("Try manually: git pull && npm install && npm run build");
83
+ s.stop(t("update.updateFailed"));
84
+ p.log.error(t("update.updateManualGit"));
83
85
  process.exit(1);
84
86
  }
85
87
  }
@@ -1,8 +1,10 @@
1
+ import { type Locale } from "./i18n/index.js";
1
2
  export interface Config {
2
3
  telegram_bot_token: string;
3
4
  user_id: number;
4
5
  hook_port: number;
5
6
  hook_secret: string;
7
+ locale: Locale;
6
8
  }
7
9
  export declare class ConfigManager {
8
10
  private static readonly CONFIG_DIR;
@@ -2,6 +2,8 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { randomBytes } from "node:crypto";
5
+ import { isValidLocale, setLocale } from "./i18n/index.js";
6
+ import { t } from "./i18n/index.js";
5
7
  export class ConfigManager {
6
8
  static CONFIG_DIR = join(homedir(), ".ccbot");
7
9
  static CONFIG_FILE = join(ConfigManager.CONFIG_DIR, "config.json");
@@ -12,12 +14,14 @@ export class ConfigManager {
12
14
  }
13
15
  catch (err) {
14
16
  if (err instanceof Error && "code" in err && err.code === "ENOENT") {
15
- throw new Error("config not found — run 'ccbot setup' first");
17
+ throw new Error(t("config.notFound"));
16
18
  }
17
- throw new Error(`read config: ${err instanceof Error ? err.message : String(err)}`);
19
+ throw new Error(t("config.readError", { error: err instanceof Error ? err.message : String(err) }));
18
20
  }
19
21
  const raw = JSON.parse(data);
20
- return ConfigManager.validate(raw);
22
+ const cfg = ConfigManager.validate(raw);
23
+ setLocale(cfg.locale);
24
+ return cfg;
21
25
  }
22
26
  static save(cfg) {
23
27
  mkdirSync(ConfigManager.CONFIG_DIR, { recursive: true });
@@ -31,37 +35,39 @@ export class ConfigManager {
31
35
  }
32
36
  static validate(data) {
33
37
  if (typeof data !== "object" || data === null) {
34
- throw new Error("config must be a JSON object");
38
+ throw new Error(t("config.mustBeObject"));
35
39
  }
36
40
  const obj = data;
37
41
  if (typeof obj.telegram_bot_token !== "string" || !obj.telegram_bot_token.includes(":")) {
38
- throw new Error("telegram_bot_token must be a string containing ':' — run 'ccbot setup'");
42
+ throw new Error(t("config.invalidToken"));
39
43
  }
40
44
  if (typeof obj.user_id !== "number" || !Number.isInteger(obj.user_id)) {
41
- throw new Error("user_id must be an integer — run 'ccbot setup'");
45
+ throw new Error(t("config.invalidUserId"));
42
46
  }
43
47
  let hookPort = 9377;
44
48
  if (obj.hook_port !== undefined) {
45
49
  if (typeof obj.hook_port !== "number" || !Number.isInteger(obj.hook_port) || obj.hook_port < 1 || obj.hook_port > 65535) {
46
- throw new Error("hook_port must be an integer between 1 and 65535");
50
+ throw new Error(t("config.invalidPort"));
47
51
  }
48
52
  hookPort = obj.hook_port;
49
53
  }
50
54
  let hookSecret;
51
55
  if (typeof obj.hook_secret === "string" && obj.hook_secret.length > 0) {
52
56
  if (!/^[a-f0-9]+$/i.test(obj.hook_secret)) {
53
- throw new Error("hook_secret must contain only hex characters (a-f, 0-9)");
57
+ throw new Error(t("config.invalidSecret"));
54
58
  }
55
59
  hookSecret = obj.hook_secret;
56
60
  }
57
61
  else {
58
62
  hookSecret = ConfigManager.generateSecret();
59
63
  }
64
+ const locale = isValidLocale(obj.locale) ? obj.locale : "en";
60
65
  const cfg = {
61
66
  telegram_bot_token: obj.telegram_bot_token,
62
67
  user_id: obj.user_id,
63
68
  hook_port: hookPort,
64
69
  hook_secret: hookSecret,
70
+ locale,
65
71
  };
66
72
  if (typeof obj.hook_secret !== "string" || obj.hook_secret.length === 0) {
67
73
  ConfigManager.save(cfg);
@@ -1,9 +1,14 @@
1
- export type NotifyFunc = (text: string) => Promise<void>;
1
+ import type { TunnelManager } from "../utils/tunnel.js";
2
+ type NotifyFunc = (text: string, responseUrl?: string) => Promise<void>;
2
3
  export declare class HookHandler {
3
4
  private notify;
4
- constructor(notify: NotifyFunc);
5
- handleStopEvent(event: unknown): void;
5
+ private hookPort;
6
+ private tunnelManager;
7
+ constructor(notify: NotifyFunc, hookPort: number, tunnelManager: TunnelManager);
8
+ handleStopEvent(event: unknown): Promise<void>;
9
+ private buildResponseUrl;
6
10
  private collectGitChanges;
7
11
  private parseGitDiffOutput;
8
12
  private parsePorcelainOutput;
9
13
  }
14
+ export {};
@@ -2,6 +2,10 @@ import { execSync } from "node:child_process";
2
2
  import { parseTranscript } from "../monitor/transcript-parser.js";
3
3
  import { formatNotification, extractProjectName, } from "../telegram/message-formatter.js";
4
4
  import { formatError } from "../utils/error-utils.js";
5
+ import { GitChangeStatus, MINI_APP_BASE_URL } from "../utils/constants.js";
6
+ import { t } from "../i18n/index.js";
7
+ import { responseStore } from "../utils/response-store.js";
8
+ import { log } from "../utils/log.js";
5
9
  const GIT_TIMEOUT_MS = 10_000;
6
10
  function isValidStopEvent(data) {
7
11
  if (typeof data !== "object" || data === null)
@@ -13,36 +17,67 @@ function isValidStopEvent(data) {
13
17
  }
14
18
  export class HookHandler {
15
19
  notify;
16
- constructor(notify) {
20
+ hookPort;
21
+ tunnelManager;
22
+ constructor(notify, hookPort, tunnelManager) {
17
23
  this.notify = notify;
24
+ this.hookPort = hookPort;
25
+ this.tunnelManager = tunnelManager;
18
26
  }
19
- handleStopEvent(event) {
27
+ async handleStopEvent(event) {
20
28
  if (!isValidStopEvent(event)) {
21
- console.log("ccbot: invalid stop event payload — missing required fields");
29
+ log(t("hook.invalidPayload"));
22
30
  return;
23
31
  }
24
- console.log(`ccbot: stop event received for session ${event.session_id} at ${event.cwd}`);
25
- let summary = { lastAssistantMessage: "", durationMs: 0, totalCostUSD: 0 };
32
+ log(t("hook.stopEventReceived", { sessionId: event.session_id, cwd: event.cwd }));
33
+ await new Promise((resolve) => setTimeout(resolve, 500));
34
+ let summary = { lastAssistantMessage: "", durationMs: 0, totalCostUSD: 0, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
26
35
  try {
27
36
  summary = parseTranscript(event.transcript_path);
28
37
  }
29
38
  catch (err) {
30
- console.log(`ccbot: failed to parse transcript: ${formatError(err)}`);
39
+ log(t("hook.transcriptFailed", { error: formatError(err) }));
31
40
  }
32
41
  const gitChanges = this.collectGitChanges(event.cwd);
33
42
  let durationMs = summary.durationMs;
34
43
  if (durationMs === 0 && summary.lastAssistantMessage) {
35
44
  durationMs = 1000;
36
45
  }
37
- const notification = formatNotification({
46
+ const data = {
38
47
  projectName: extractProjectName(event.cwd),
39
48
  responseSummary: summary.lastAssistantMessage,
40
49
  durationMs,
41
50
  gitChanges,
51
+ inputTokens: summary.inputTokens,
52
+ outputTokens: summary.outputTokens,
53
+ cacheCreationTokens: summary.cacheCreationTokens,
54
+ cacheReadTokens: summary.cacheReadTokens,
55
+ };
56
+ const notification = formatNotification(data);
57
+ const responseUrl = this.buildResponseUrl(data);
58
+ this.notify(notification, responseUrl).catch((err) => {
59
+ log(t("hook.notificationFailed", { error: formatError(err) }));
42
60
  });
43
- this.notify(notification).catch((err) => {
44
- console.log(`ccbot: failed to send notification: ${formatError(err)}`);
61
+ }
62
+ buildResponseUrl(data) {
63
+ const id = responseStore.save({
64
+ projectName: data.projectName,
65
+ responseSummary: data.responseSummary,
66
+ durationMs: data.durationMs,
67
+ gitChanges: data.gitChanges,
68
+ inputTokens: data.inputTokens,
69
+ outputTokens: data.outputTokens,
70
+ cacheCreationTokens: data.cacheCreationTokens,
71
+ cacheReadTokens: data.cacheReadTokens,
72
+ });
73
+ const apiBase = this.tunnelManager.getPublicUrl() || `http://localhost:${this.hookPort}`;
74
+ const params = new URLSearchParams({
75
+ id,
76
+ api: apiBase,
77
+ p: data.projectName,
78
+ d: String(data.durationMs),
45
79
  });
80
+ return `${MINI_APP_BASE_URL}/response.html?${params.toString()}`;
46
81
  }
47
82
  collectGitChanges(cwd) {
48
83
  try {
@@ -60,7 +95,7 @@ export class HookHandler {
60
95
  });
61
96
  for (const file of untrackedOutput.trim().split("\n")) {
62
97
  if (file)
63
- changes.push({ file, status: "added" });
98
+ changes.push({ file, status: GitChangeStatus.Added });
64
99
  }
65
100
  }
66
101
  catch { }
@@ -88,13 +123,13 @@ export class HookHandler {
88
123
  const parts = line.split("\t");
89
124
  if (parts.length < 2)
90
125
  continue;
91
- let status = "modified";
126
+ let status = GitChangeStatus.Modified;
92
127
  if (parts[0].startsWith("A"))
93
- status = "added";
128
+ status = GitChangeStatus.Added;
94
129
  else if (parts[0].startsWith("D"))
95
- status = "deleted";
130
+ status = GitChangeStatus.Deleted;
96
131
  else if (parts[0].startsWith("R"))
97
- status = "renamed";
132
+ status = GitChangeStatus.Renamed;
98
133
  changes.push({ file: parts[1], status });
99
134
  }
100
135
  return changes;
@@ -106,17 +141,17 @@ export class HookHandler {
106
141
  continue;
107
142
  const statusCode = line.slice(0, 2).trim();
108
143
  const file = line.slice(3).trim();
109
- let status = "modified";
144
+ let status = GitChangeStatus.Modified;
110
145
  switch (statusCode) {
111
146
  case "??":
112
147
  case "A":
113
- status = "added";
148
+ status = GitChangeStatus.Added;
114
149
  break;
115
150
  case "D":
116
- status = "deleted";
151
+ status = GitChangeStatus.Deleted;
117
152
  break;
118
153
  case "R":
119
- status = "renamed";
154
+ status = GitChangeStatus.Renamed;
120
155
  break;
121
156
  }
122
157
  changes.push({ file, status });
@@ -2,6 +2,7 @@ export declare class HookInstaller {
2
2
  private static readonly HOOKS_DIR;
3
3
  private static readonly SCRIPT_PATH;
4
4
  private static readonly SETTINGS_PATH;
5
+ static isInstalled(): boolean;
5
6
  static install(hookPort: number, hookSecret: string): void;
6
7
  static uninstall(): void;
7
8
  private static installScript;
@@ -1,23 +1,34 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, unlinkSync, rmdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { t } from "../i18n/index.js";
4
5
  export class HookInstaller {
5
6
  static HOOKS_DIR = join(homedir(), ".ccbot", "hooks");
6
7
  static SCRIPT_PATH = join(HookInstaller.HOOKS_DIR, "stop-notify.sh");
7
8
  static SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
9
+ static isInstalled() {
10
+ try {
11
+ const settings = HookInstaller.readSettings();
12
+ const hooks = (settings.hooks ?? {});
13
+ const existingStop = (hooks.Stop ?? []);
14
+ return existingStop.some((entry) => {
15
+ const entryHooks = entry.hooks;
16
+ return entryHooks?.some((h) => typeof h.command === "string" && h.command.includes("ccbot"));
17
+ });
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
8
23
  static install(hookPort, hookSecret) {
9
24
  if (!Number.isInteger(hookPort) || hookPort < 1 || hookPort > 65535) {
10
- throw new Error(`invalid hook port: ${hookPort} (must be 1-65535)`);
25
+ throw new Error(t("config.invalidHookPort", { port: hookPort }));
11
26
  }
12
27
  const settings = HookInstaller.readSettings();
13
28
  const hooks = (settings.hooks ?? {});
14
29
  const existingStop = (hooks.Stop ?? []);
15
- const alreadyInstalled = existingStop.some((entry) => {
16
- const entryHooks = entry.hooks;
17
- return entryHooks?.some((h) => typeof h.command === "string" && h.command.includes("ccbot"));
18
- });
19
- if (alreadyInstalled) {
20
- throw new Error("ccbot hook already installed");
30
+ if (HookInstaller.isInstalled()) {
31
+ throw new Error(t("config.hookAlreadyInstalled"));
21
32
  }
22
33
  existingStop.push({
23
34
  hooks: [{ type: "command", command: HookInstaller.SCRIPT_PATH, timeout: 10 }],
@@ -91,7 +102,7 @@ curl -s -X POST http://localhost:${hookPort}/hook/stop \\
91
102
  if (err instanceof Error && "code" in err && err.code === "ENOENT") {
92
103
  return {};
93
104
  }
94
- throw new Error(`read settings: ${err instanceof Error ? err.message : String(err)}`);
105
+ throw new Error(t("config.readSettingsError", { error: err instanceof Error ? err.message : String(err) }));
95
106
  }
96
107
  }
97
108
  }
@@ -5,7 +5,8 @@ export declare class HookServer {
5
5
  private port;
6
6
  private secret;
7
7
  private handler;
8
- constructor(port: number, secret: string, handler: HookHandler);
8
+ constructor(port: number, secret: string);
9
+ setHandler(handler: HookHandler): void;
9
10
  start(): void;
10
11
  stop(): Promise<void>;
11
12
  private createApp;