unforgit 0.1.0 → 0.3.2

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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command28 } from "commander";
4
+ import { Command as Command29 } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
7
  import fs2 from "fs";
@@ -156,46 +156,66 @@ Trigger a save with the \`unforgit_add\` tool immediately after:
156
156
  - Do NOT save trivial changes, obvious things, or info already in the codebase docs
157
157
  - Quality over quantity \u2014 only save what's genuinely useful for future sessions
158
158
  - **Always write memory text in English**, regardless of the language the user is speaking`;
159
- function fileContainsUnforgit(filePath) {
160
- if (!fs.existsSync(filePath)) return false;
161
- return fs.readFileSync(filePath, "utf-8").includes(UNFORGIT_MARKER);
162
- }
163
159
  function upsertJsonMcp(filePath, serverKey, mcpEntry) {
164
160
  const dir = path.dirname(filePath);
165
161
  fs.mkdirSync(dir, { recursive: true });
166
- if (!fs.existsSync(filePath)) {
167
- const config = { [serverKey]: { unforgit: mcpEntry } };
168
- fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
169
- return { path: filePath, action: "created" };
170
- }
171
- const existing = JSON.parse(fs.readFileSync(filePath, "utf-8"));
172
- const servers = existing[serverKey];
173
- if (servers?.unforgit) {
174
- return { path: filePath, action: "exists" };
175
- }
176
- existing[serverKey] = existing[serverKey] || {};
177
- existing[serverKey].unforgit = mcpEntry;
178
- fs.writeFileSync(
179
- filePath,
180
- JSON.stringify(existing, null, 2) + "\n",
181
- "utf-8"
182
- );
183
- return { path: filePath, action: "updated" };
162
+ const fd = fs.openSync(filePath, "a+");
163
+ try {
164
+ const raw = fs.readFileSync(fd, "utf-8");
165
+ if (raw.trim().length === 0) {
166
+ const config = { [serverKey]: { unforgit: mcpEntry } };
167
+ fs.writeSync(fd, JSON.stringify(config, null, 2) + "\n", 0, "utf-8");
168
+ return { path: filePath, action: "created" };
169
+ }
170
+ const existing = JSON.parse(raw);
171
+ const servers = existing[serverKey];
172
+ if (servers?.unforgit) {
173
+ return { path: filePath, action: "exists" };
174
+ }
175
+ existing[serverKey] = existing[serverKey] || {};
176
+ existing[serverKey].unforgit = mcpEntry;
177
+ fs.ftruncateSync(fd, 0);
178
+ fs.writeSync(fd, JSON.stringify(existing, null, 2) + "\n", 0, "utf-8");
179
+ return { path: filePath, action: "updated" };
180
+ } finally {
181
+ fs.closeSync(fd);
182
+ }
184
183
  }
185
184
  function appendOrCreateMarkdown(filePath, content) {
186
185
  const dir = path.dirname(filePath);
187
186
  fs.mkdirSync(dir, { recursive: true });
188
- if (!fs.existsSync(filePath)) {
189
- fs.writeFileSync(filePath, content + "\n", "utf-8");
190
- return { path: filePath, action: "created" };
187
+ const fd = fs.openSync(filePath, "a+");
188
+ try {
189
+ const existing = fs.readFileSync(fd, "utf-8");
190
+ if (existing.length === 0) {
191
+ fs.writeSync(fd, content + "\n", 0, "utf-8");
192
+ return { path: filePath, action: "created" };
193
+ }
194
+ if (existing.includes(UNFORGIT_MARKER)) {
195
+ return { path: filePath, action: "exists" };
196
+ }
197
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
198
+ fs.writeSync(fd, separator + content + "\n", void 0, "utf-8");
199
+ return { path: filePath, action: "updated" };
200
+ } finally {
201
+ fs.closeSync(fd);
191
202
  }
192
- if (fileContainsUnforgit(filePath)) {
193
- return { path: filePath, action: "exists" };
203
+ }
204
+ function replaceMarkdownIfMissingMarker(filePath, content) {
205
+ const dir = path.dirname(filePath);
206
+ fs.mkdirSync(dir, { recursive: true });
207
+ const fd = fs.openSync(filePath, "a+");
208
+ try {
209
+ const existing = fs.readFileSync(fd, "utf-8");
210
+ if (existing.includes(UNFORGIT_MARKER)) {
211
+ return { path: filePath, action: "exists" };
212
+ }
213
+ fs.ftruncateSync(fd, 0);
214
+ fs.writeSync(fd, content, 0, "utf-8");
215
+ return { path: filePath, action: existing.length === 0 ? "created" : "updated" };
216
+ } finally {
217
+ fs.closeSync(fd);
194
218
  }
195
- const existing = fs.readFileSync(filePath, "utf-8");
196
- const separator = existing.endsWith("\n") ? "\n" : "\n\n";
197
- fs.writeFileSync(filePath, existing + separator + content + "\n", "utf-8");
198
- return { path: filePath, action: "updated" };
199
219
  }
200
220
  var CURSOR_RULE_CONTENT = `---
201
221
  description: Auto-manage repository memory with Unforgit via MCP tools
@@ -207,14 +227,7 @@ ${MEMORY_INSTRUCTIONS}
207
227
  function setupCursor(cwd4) {
208
228
  const rulesDir = path.join(cwd4, ".cursor", "rules");
209
229
  const rulePath = path.join(rulesDir, "unforgit-memory.mdc");
210
- let rules;
211
- if (fileContainsUnforgit(rulePath)) {
212
- rules = { path: rulePath, action: "exists" };
213
- } else {
214
- fs.mkdirSync(rulesDir, { recursive: true });
215
- fs.writeFileSync(rulePath, CURSOR_RULE_CONTENT, "utf-8");
216
- rules = { path: rulePath, action: "created" };
217
- }
230
+ const rules = replaceMarkdownIfMissingMarker(rulePath, CURSOR_RULE_CONTENT);
218
231
  const mcpPath = path.join(cwd4, ".cursor", "mcp.json");
219
232
  const mcp = upsertJsonMcp(mcpPath, "mcpServers", {
220
233
  command: "unforgit-mcp",
@@ -2895,16 +2908,133 @@ embeddingsCommand.command("clear").description("Remove all embeddings (requires
2895
2908
  });
2896
2909
 
2897
2910
  // src/commands/reset.ts
2898
- import { Command as Command24 } from "commander";
2899
- import { loadConfig as loadConfig21, getDbPath as getDbPath18 } from "unforgit-config";
2911
+ import { Command as Command25 } from "commander";
2912
+ import { loadConfig as loadConfig21, getDbPath as getDbPath19 } from "unforgit-config";
2900
2913
  import { LocalStore as LocalStore18 } from "unforgit-db";
2901
2914
  import { RemoteClient as RemoteClient12 } from "unforgit-config";
2902
- var resetCommand = new Command24("reset").description("Permanently delete all memories and related data").option("--local", "Reset local store only").option("--remote", "Reset remote store only").option("--force", "Skip confirmation prompt").addHelpText("after", `
2915
+
2916
+ // src/commands/backups.ts
2917
+ import fs4 from "fs";
2918
+ import path3 from "path";
2919
+ import { Command as Command24 } from "commander";
2920
+ import { getDbPath as getDbPath18 } from "unforgit-config";
2921
+ function formatBackupTimestamp(date) {
2922
+ return date.toISOString().replace(/[-:]/g, "").replace("T", "-").replace(/\.\d{3}Z$/, "");
2923
+ }
2924
+ function backupRootForDb(dbPath) {
2925
+ return path3.join(path3.dirname(dbPath), "backups");
2926
+ }
2927
+ function describeBackup(dir) {
2928
+ const files = fs4.readdirSync(dir).filter((file) => file === "local.db" || file === "local.db-wal" || file === "local.db-shm").sort();
2929
+ const sizeBytes = files.reduce((total, file) => total + fs4.statSync(path3.join(dir, file)).size, 0);
2930
+ const stat = fs4.statSync(dir);
2931
+ return {
2932
+ name: path3.basename(dir),
2933
+ dir,
2934
+ files,
2935
+ sizeBytes,
2936
+ createdAt: stat.mtime.toISOString()
2937
+ };
2938
+ }
2939
+ function resolveBackupDir(backupRoot, backupName) {
2940
+ if (backupName !== path3.basename(backupName)) {
2941
+ throw new Error("Invalid backup name");
2942
+ }
2943
+ const resolvedRoot = path3.resolve(backupRoot);
2944
+ const resolvedBackup = path3.resolve(resolvedRoot, backupName);
2945
+ if (!resolvedBackup.startsWith(`${resolvedRoot}${path3.sep}`)) {
2946
+ throw new Error("Invalid backup name");
2947
+ }
2948
+ return resolvedBackup;
2949
+ }
2950
+ function createLocalResetBackup(dbPath, now = /* @__PURE__ */ new Date()) {
2951
+ if (!fs4.existsSync(dbPath)) {
2952
+ return null;
2953
+ }
2954
+ const backupRoot = backupRootForDb(dbPath);
2955
+ const baseName = `reset-${formatBackupTimestamp(now)}`;
2956
+ let backupDir = path3.join(backupRoot, baseName);
2957
+ let suffix = 1;
2958
+ while (fs4.existsSync(backupDir)) {
2959
+ backupDir = path3.join(backupRoot, `${baseName}-${suffix++}`);
2960
+ }
2961
+ fs4.mkdirSync(backupDir, { recursive: true, mode: 448 });
2962
+ for (const source of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
2963
+ if (!fs4.existsSync(source)) continue;
2964
+ fs4.copyFileSync(source, path3.join(backupDir, path3.basename(source)));
2965
+ }
2966
+ return describeBackup(backupDir);
2967
+ }
2968
+ function listLocalResetBackups(dbPath) {
2969
+ const backupRoot = backupRootForDb(dbPath);
2970
+ if (!fs4.existsSync(backupRoot)) {
2971
+ return [];
2972
+ }
2973
+ return fs4.readdirSync(backupRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.startsWith("reset-")).map((entry) => describeBackup(path3.join(backupRoot, entry.name))).filter((backup) => backup.files.includes("local.db")).sort((a, b) => b.name.localeCompare(a.name));
2974
+ }
2975
+ function restoreLocalResetBackup(dbPath, backupName, now = /* @__PURE__ */ new Date()) {
2976
+ const backupRoot = backupRootForDb(dbPath);
2977
+ const backupDir = resolveBackupDir(backupRoot, backupName);
2978
+ if (!fs4.existsSync(backupDir) || !fs4.statSync(backupDir).isDirectory()) {
2979
+ throw new Error(`Backup not found: ${backupName}`);
2980
+ }
2981
+ const restoredFrom = describeBackup(backupDir);
2982
+ if (!restoredFrom.files.includes("local.db")) {
2983
+ throw new Error(`Backup is missing local.db: ${backupName}`);
2984
+ }
2985
+ const safetyBackup = createLocalResetBackup(dbPath, now);
2986
+ fs4.mkdirSync(path3.dirname(dbPath), { recursive: true });
2987
+ for (const target of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
2988
+ if (fs4.existsSync(target)) {
2989
+ fs4.rmSync(target, { force: true });
2990
+ }
2991
+ }
2992
+ for (const file of restoredFrom.files) {
2993
+ fs4.copyFileSync(path3.join(backupDir, file), path3.join(path3.dirname(dbPath), file));
2994
+ }
2995
+ return { restoredFrom, safetyBackup };
2996
+ }
2997
+ var backupsCommand = new Command24("backups").description("List and restore local reset backups");
2998
+ backupsCommand.command("list").description("List local backups created before destructive resets/restores").action(() => {
2999
+ const backups = listLocalResetBackups(getDbPath18());
3000
+ if (backups.length === 0) {
3001
+ logger.info("No local reset backups found.");
3002
+ return;
3003
+ }
3004
+ for (const backup of backups) {
3005
+ logger.info(`${backup.name} ${backup.sizeBytes} bytes ${backup.dir}`);
3006
+ }
3007
+ });
3008
+ backupsCommand.command("restore").argument("<name>", "Backup directory name, for example reset-20260610-123456").description("Restore a local reset backup into the active local database").option("--force", "Skip confirmation prompt").action(async (name, opts) => {
3009
+ if (!opts.force) {
3010
+ const ok = await confirm(
3011
+ "This will replace the active local database after creating a safety backup. Continue?"
3012
+ );
3013
+ if (!ok) {
3014
+ logger.info("Aborted.");
3015
+ return;
3016
+ }
3017
+ }
3018
+ try {
3019
+ const result = restoreLocalResetBackup(getDbPath18(), name);
3020
+ logger.info(`Restored local database from ${result.restoredFrom.name}`);
3021
+ if (result.safetyBackup) {
3022
+ logger.info(`Previous local database safety backup: ${result.safetyBackup.dir}`);
3023
+ }
3024
+ } catch (err) {
3025
+ logger.error(err instanceof Error ? err.message : String(err));
3026
+ process.exit(EXIT_ERROR);
3027
+ }
3028
+ });
3029
+
3030
+ // src/commands/reset.ts
3031
+ var resetCommand = new Command25("reset").description("Permanently delete all memories and related data").option("--local", "Reset local store only").option("--remote", "Reset remote store only").option("--force", "Skip confirmation prompt").option("--no-backup", "Skip automatic local database backup before local reset").addHelpText("after", `
2903
3032
  Examples:
2904
3033
  unforgit reset Reset both local and remote
2905
3034
  unforgit reset --local Reset local store only
2906
3035
  unforgit reset --remote Reset remote store only
2907
- unforgit reset --force Skip confirmation`).action(async (opts) => {
3036
+ unforgit reset --force Skip confirmation
3037
+ unforgit reset --no-backup Skip local backup before reset`).action(async (opts) => {
2908
3038
  const resetLocal = opts.local || !opts.local && !opts.remote;
2909
3039
  const resetRemote = opts.remote || !opts.local && !opts.remote;
2910
3040
  const targets = [
@@ -2924,7 +3054,21 @@ Examples:
2924
3054
  }
2925
3055
  const config = loadConfig21();
2926
3056
  if (resetLocal) {
2927
- const store = new LocalStore18(getDbPath18());
3057
+ const dbPath = getDbPath19();
3058
+ if (opts.backup !== false) {
3059
+ try {
3060
+ const backup = createLocalResetBackup(dbPath);
3061
+ if (backup) {
3062
+ logger.info(`Created local reset backup: ${backup.dir}`);
3063
+ }
3064
+ } catch (err) {
3065
+ logger.error(
3066
+ `Failed to create local reset backup: ${err instanceof Error ? err.message : String(err)}`
3067
+ );
3068
+ process.exit(EXIT_ERROR);
3069
+ }
3070
+ }
3071
+ const store = new LocalStore18(dbPath);
2928
3072
  try {
2929
3073
  const result = store.resetAll();
2930
3074
  logger.info(`Local reset complete:`);
@@ -2965,22 +3109,24 @@ Examples:
2965
3109
  });
2966
3110
 
2967
3111
  // src/commands/doctor.ts
2968
- import { Command as Command25 } from "commander";
2969
- import { loadConfig as loadConfig22, getDbPath as getDbPath19, isInitialized as isInitialized16, getConfigPath as getConfigPath2 } from "unforgit-config";
3112
+ import { Command as Command26 } from "commander";
3113
+ import { loadConfig as loadConfig22, getDbPath as getDbPath20, isInitialized as isInitialized16, getConfigPath as getConfigPath2 } from "unforgit-config";
2970
3114
  import { LocalStore as LocalStore19 } from "unforgit-db";
2971
3115
  import { RemoteClient as RemoteClient13 } from "unforgit-config";
2972
- import fs4 from "fs";
2973
- var doctorCommand = new Command25("doctor").description("Check system health and diagnose common issues").action(async () => {
3116
+ import fs5 from "fs";
3117
+ import YAML from "yaml";
3118
+ var doctorCommand = new Command26("doctor").description("Check system health and diagnose common issues").action(async () => {
2974
3119
  const results = [];
2975
3120
  if (!isInitialized16()) {
2976
3121
  results.push({
2977
3122
  check: "initialization",
2978
3123
  status: "error",
2979
- message: "Not an unforgit repository. Run 'unforgit init' first."
3124
+ message: "Not an unforgit repository. Run 'unforgit init' first.",
3125
+ fix: "Run 'unforgit init' in the repository root."
2980
3126
  });
2981
3127
  if (isJsonMode()) {
2982
- outputJson({ results });
2983
- return;
3128
+ outputJson(buildPayload(results));
3129
+ process.exit(EXIT_CONFIG_ERROR);
2984
3130
  }
2985
3131
  printResults(results);
2986
3132
  process.exit(EXIT_CONFIG_ERROR);
@@ -2990,15 +3136,27 @@ var doctorCommand = new Command25("doctor").description("Check system health and
2990
3136
  try {
2991
3137
  const config = loadConfig22();
2992
3138
  results.push({ check: "config", status: "ok", message: `Valid config at ${configPath}` });
3139
+ const deprecatedConfigKeys = findDeprecatedConfigKeys(configPath);
3140
+ if (deprecatedConfigKeys.length > 0) {
3141
+ results.push({
3142
+ check: "config-deprecated-secrets",
3143
+ status: "warn",
3144
+ message: `Deprecated secret-bearing config key(s): ${deprecatedConfigKeys.join(", ")}`,
3145
+ fix: "Move secrets to environment variables and remove them from unforgit.yaml."
3146
+ });
3147
+ } else {
3148
+ results.push({ check: "config-deprecated-secrets", status: "ok", message: "No deprecated secret keys in config" });
3149
+ }
2993
3150
  if (process.platform !== "win32") {
2994
3151
  try {
2995
- const stat = fs4.statSync(configPath);
3152
+ const stat = fs5.statSync(configPath);
2996
3153
  const mode = stat.mode & 511;
2997
3154
  if (mode & 36) {
2998
3155
  results.push({
2999
3156
  check: "config-permissions",
3000
3157
  status: "warn",
3001
- message: `Config readable by others (mode ${mode.toString(8)}). Run: chmod 600 ${configPath}`
3158
+ message: `Config readable by others (mode ${mode.toString(8)}). Run: chmod 600 ${configPath}`,
3159
+ fix: `Run 'chmod 600 ${configPath}'.`
3002
3160
  });
3003
3161
  } else {
3004
3162
  results.push({ check: "config-permissions", status: "ok", message: "Config file permissions are secure" });
@@ -3007,12 +3165,18 @@ var doctorCommand = new Command25("doctor").description("Check system health and
3007
3165
  results.push({ check: "config-permissions", status: "warn", message: "Could not check config file permissions" });
3008
3166
  }
3009
3167
  }
3010
- const store = new LocalStore19(getDbPath19());
3168
+ const store = new LocalStore19(getDbPath20());
3011
3169
  try {
3012
3170
  const orgId = config.remote.orgId || "local";
3013
3171
  const repoId = config.remote.repoId || "local";
3014
3172
  store.list({ orgId, repoId, limit: 1 });
3015
3173
  results.push({ check: "local-db", status: "ok", message: "SQLite database is accessible" });
3174
+ const memoryStats = store.stats(orgId, repoId);
3175
+ results.push({
3176
+ check: "memory-stats",
3177
+ status: "ok",
3178
+ message: `${memoryStats.total} memories (${memoryStats.byType.episodic} episodic, ${memoryStats.byType.semantic} semantic, ${memoryStats.byType.procedural} procedural)`
3179
+ });
3016
3180
  const stats = store.getEmbeddingStats(orgId, repoId);
3017
3181
  if (stats.total === 0) {
3018
3182
  results.push({ check: "embeddings", status: "ok", message: "No memories yet" });
@@ -3023,16 +3187,29 @@ var doctorCommand = new Command25("doctor").description("Check system health and
3023
3187
  results.push({
3024
3188
  check: "embeddings",
3025
3189
  status: "warn",
3026
- message: `${stats.withoutEmbedding}/${stats.total} memories lack embeddings (${pct}% coverage). Run 'unforgit embeddings backfill'`
3190
+ message: `${stats.withoutEmbedding}/${stats.total} memories lack embeddings (${pct}% coverage). Run 'unforgit embeddings backfill'`,
3191
+ fix: "Run 'unforgit embeddings backfill'."
3027
3192
  });
3028
3193
  }
3029
3194
  const pendingPush = store.getPendingPush();
3030
3195
  const conflicts = store.getConflicts();
3196
+ const unsyncedTombstones = store.getUnsyncedTombstones(orgId, repoId);
3197
+ if (unsyncedTombstones.length > 0) {
3198
+ results.push({
3199
+ check: "tombstones",
3200
+ status: "warn",
3201
+ message: `${unsyncedTombstones.length} deleted memory tombstone(s) pending sync`,
3202
+ fix: "Run 'unforgit push' to sync deletions."
3203
+ });
3204
+ } else {
3205
+ results.push({ check: "tombstones", status: "ok", message: "No deleted memory tombstones pending sync" });
3206
+ }
3031
3207
  if (conflicts.length > 0) {
3032
3208
  results.push({
3033
3209
  check: "sync",
3034
3210
  status: "warn",
3035
- message: `${conflicts.length} sync conflict(s) need resolution`
3211
+ message: `${conflicts.length} sync conflict(s) need resolution`,
3212
+ fix: "Resolve conflicts with 'unforgit pull --force' or 'unforgit push --force' after reviewing the desired source of truth."
3036
3213
  });
3037
3214
  } else if (pendingPush.length > 0) {
3038
3215
  results.push({
@@ -3052,13 +3229,14 @@ var doctorCommand = new Command25("doctor").description("Check system health and
3052
3229
  results.push({
3053
3230
  check: "auth",
3054
3231
  status: "warn",
3055
- message: "No API key configured. Set the UNFORGIT_API_KEY environment variable."
3232
+ message: "No API key configured. Set the UNFORGIT_API_KEY environment variable.",
3233
+ fix: "Set UNFORGIT_API_KEY in your shell or secret manager before remote sync."
3056
3234
  });
3057
3235
  } else {
3058
3236
  results.push({
3059
3237
  check: "auth",
3060
3238
  status: "ok",
3061
- message: `API key configured: ${maskKey(apiKey)}`
3239
+ message: "API key configured ([REDACTED])"
3062
3240
  });
3063
3241
  }
3064
3242
  try {
@@ -3071,58 +3249,107 @@ var doctorCommand = new Command25("doctor").description("Check system health and
3071
3249
  await client.listApiKeys();
3072
3250
  results.push({ check: "remote-auth", status: "ok", message: "API key is valid" });
3073
3251
  } catch {
3074
- results.push({ check: "remote-auth", status: "error", message: "API key is invalid or expired" });
3252
+ results.push({
3253
+ check: "remote-auth",
3254
+ status: "error",
3255
+ message: "API key is invalid or expired",
3256
+ fix: "Create a new API key with 'unforgit keys create' or update UNFORGIT_API_KEY."
3257
+ });
3075
3258
  }
3076
3259
  }
3077
3260
  } else {
3078
3261
  results.push({
3079
3262
  check: "remote",
3080
3263
  status: "error",
3081
- message: `Server returned HTTP ${res.status}`
3264
+ message: `Server returned HTTP ${res.status}`,
3265
+ fix: "Check the Unforgit API server health endpoint and remote.url in unforgit.yaml."
3082
3266
  });
3083
3267
  }
3084
3268
  } catch (err) {
3085
3269
  results.push({
3086
3270
  check: "remote",
3087
3271
  status: "error",
3088
- message: `Cannot connect to ${config.remote.url}: ${err instanceof Error ? err.message : err}`
3272
+ message: `Cannot connect to ${config.remote.url}: ${err instanceof Error ? err.message : err}`,
3273
+ fix: "Start the Unforgit API server or update remote.url in unforgit.yaml."
3089
3274
  });
3090
3275
  }
3091
3276
  } else {
3092
- results.push({ check: "remote", status: "warn", message: "No remote URL configured" });
3277
+ results.push({
3278
+ check: "remote",
3279
+ status: "warn",
3280
+ message: "No remote URL configured",
3281
+ fix: "Run 'unforgit remote add origin <url>' if this repo should sync remotely."
3282
+ });
3093
3283
  }
3094
3284
  const openaiKey = process.env.OPENAI_API_KEY;
3095
3285
  if (openaiKey) {
3096
- results.push({ check: "openai", status: "ok", message: `OpenAI API key configured: ${maskKey(openaiKey)}` });
3286
+ results.push({ check: "openai", status: "ok", message: "OpenAI API key configured ([REDACTED])" });
3097
3287
  } else {
3098
3288
  results.push({
3099
3289
  check: "openai",
3100
3290
  status: "warn",
3101
- message: "No OpenAI API key. Auto-consolidation and embeddings backfill require it."
3291
+ message: "No OpenAI API key. Auto-consolidation and embeddings backfill require it.",
3292
+ fix: "Set OPENAI_API_KEY before running embedding backfill or AI consolidation."
3102
3293
  });
3103
3294
  }
3104
3295
  } catch (err) {
3105
3296
  results.push({
3106
3297
  check: "config",
3107
3298
  status: "error",
3108
- message: err instanceof Error ? err.message : String(err)
3299
+ message: err instanceof Error ? err.message : String(err),
3300
+ fix: `Fix ${configPath} or re-run 'unforgit init'.`
3109
3301
  });
3110
3302
  }
3111
3303
  if (isJsonMode()) {
3112
- outputJson({ results });
3304
+ outputJson(buildPayload(results));
3305
+ exitForResults(results);
3113
3306
  return;
3114
3307
  }
3115
3308
  printResults(results);
3309
+ exitForResults(results);
3116
3310
  });
3311
+ function summarize(results) {
3312
+ return {
3313
+ ok: results.filter((r) => r.status === "ok").length,
3314
+ warnings: results.filter((r) => r.status === "warn").length,
3315
+ errors: results.filter((r) => r.status === "error").length
3316
+ };
3317
+ }
3318
+ function buildPayload(results) {
3319
+ return { summary: summarize(results), results };
3320
+ }
3321
+ function exitForResults(results) {
3322
+ if (results.some((r) => r.status === "error")) {
3323
+ process.exit(EXIT_ERROR);
3324
+ }
3325
+ }
3326
+ function findDeprecatedConfigKeys(configPath) {
3327
+ try {
3328
+ const raw = fs5.readFileSync(configPath, "utf-8");
3329
+ const parsed = YAML.parse(raw) ?? {};
3330
+ const deprecated = [];
3331
+ if (parsed.remote?.apiKey) {
3332
+ deprecated.push("remote.apiKey");
3333
+ }
3334
+ if (parsed.openaiApiKey) {
3335
+ deprecated.push("openaiApiKey");
3336
+ }
3337
+ return deprecated;
3338
+ } catch {
3339
+ return [];
3340
+ }
3341
+ }
3117
3342
  function printResults(results) {
3118
3343
  const icons = { ok: "[ok]", warn: "[!!]", error: "[ERR]" };
3119
3344
  logger.info("Unforgit Doctor\n");
3120
3345
  for (const r of results) {
3121
3346
  const icon = icons[r.status];
3122
3347
  logger.info(` ${icon} ${r.check}: ${r.message}`);
3348
+ if (r.fix) {
3349
+ logger.info(` fix: ${r.fix}`);
3350
+ }
3123
3351
  }
3124
- const errors = results.filter((r) => r.status === "error").length;
3125
- const warnings = results.filter((r) => r.status === "warn").length;
3352
+ const { errors, warnings } = summarize(results);
3126
3353
  logger.info("");
3127
3354
  if (errors > 0) {
3128
3355
  logger.info(`${errors} error(s), ${warnings} warning(s)`);
@@ -3134,7 +3361,7 @@ function printResults(results) {
3134
3361
  }
3135
3362
 
3136
3363
  // src/commands/completion.ts
3137
- import { Command as Command26 } from "commander";
3364
+ import { Command as Command27 } from "commander";
3138
3365
  var BASH_COMPLETION = `
3139
3366
  _unforgit_completions() {
3140
3367
  local cur prev commands
@@ -3279,7 +3506,7 @@ complete -c unforgit -l verbose -d 'Enable verbose output'
3279
3506
  complete -c unforgit -l quiet -d 'Suppress non-essential output'
3280
3507
  complete -c unforgit -l json -d 'Output as JSON'
3281
3508
  `.trim();
3282
- var completionCommand = new Command26("completion").description("Generate shell completion scripts").argument("<shell>", "Shell type (bash, zsh, fish)").addHelpText("after", `
3509
+ var completionCommand = new Command27("completion").description("Generate shell completion scripts").argument("<shell>", "Shell type (bash, zsh, fish)").addHelpText("after", `
3283
3510
  Examples:
3284
3511
  unforgit completion bash >> ~/.bashrc
3285
3512
  unforgit completion zsh >> ~/.zshrc
@@ -3301,8 +3528,8 @@ Examples:
3301
3528
  });
3302
3529
 
3303
3530
  // src/commands/curate.ts
3304
- import { Command as Command27 } from "commander";
3305
- import { getDbPath as getDbPath20, isInitialized as isInitialized17, loadConfig as loadConfig23 } from "unforgit-config";
3531
+ import { Command as Command28 } from "commander";
3532
+ import { getDbPath as getDbPath21, isInitialized as isInitialized17, loadConfig as loadConfig23 } from "unforgit-config";
3306
3533
  import { LocalStore as LocalStore20 } from "unforgit-db";
3307
3534
  import { RemoteClient as RemoteClient14 } from "unforgit-config";
3308
3535
  import { runLocalLifecycleMaintenance } from "unforgit-core";
@@ -3318,7 +3545,7 @@ function formatCandidatePreview2(candidate) {
3318
3545
  }
3319
3546
  return lines.join("\n");
3320
3547
  }
3321
- var curateCommand = new Command27("curate").description("Preview or run lifecycle maintenance for repository memories").option("--remote", "Run maintenance against the remote server").option("--execute", "Apply changes instead of previewing them").option("--model <model>", "OpenAI model to use for consolidation").option("--no-preserve", "Do not preserve original memories during consolidation").addHelpText("after", `
3548
+ var curateCommand = new Command28("curate").description("Preview or run lifecycle maintenance for repository memories").option("--remote", "Run maintenance against the remote server").option("--execute", "Apply changes instead of previewing them").option("--model <model>", "OpenAI model to use for consolidation").option("--no-preserve", "Do not preserve original memories during consolidation").addHelpText("after", `
3322
3549
  Examples:
3323
3550
  unforgit curate
3324
3551
  unforgit curate --execute
@@ -3338,7 +3565,7 @@ Examples:
3338
3565
  dryRun,
3339
3566
  model: opts.model,
3340
3567
  preserveOriginals: opts.preserve !== false
3341
- }) : await runLocalCurate(getDbPath20(), orgId, repoId, {
3568
+ }) : await runLocalCurate(getDbPath21(), orgId, repoId, {
3342
3569
  dryRun,
3343
3570
  model: opts.model,
3344
3571
  preserveOriginals: opts.preserve !== false,
@@ -3452,7 +3679,7 @@ process.on("unhandledRejection", (err) => {
3452
3679
  runCleanup();
3453
3680
  process.exit(EXIT_ERROR);
3454
3681
  });
3455
- var program = new Command28();
3682
+ var program = new Command29();
3456
3683
  program.name("unforgit").description("Unforgit \u2014 repository memory for agents and developers").version(pkg.version).option("--verbose", "Enable verbose output").option("--quiet", "Suppress non-essential output").option("--json", "Output results as JSON (for scripting)").hook("preAction", () => {
3457
3684
  const opts = program.opts();
3458
3685
  if (opts.quiet) setVerbosity(0);
@@ -3489,6 +3716,7 @@ program.addCommand(authCommand);
3489
3716
  program.addCommand(configCommand);
3490
3717
  program.addCommand(embeddingsCommand);
3491
3718
  program.addCommand(resetCommand);
3719
+ program.addCommand(backupsCommand);
3492
3720
  program.addCommand(doctorCommand);
3493
3721
  program.addCommand(curateCommand);
3494
3722
  program.addCommand(completionCommand);