@amirdaraee/namewise 0.9.0 → 1.0.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 (55) hide show
  1. package/.claude/agents/test-writer.md +39 -0
  2. package/.claude/settings.json +41 -0
  3. package/.claude/skills/new-command/SKILL.md +77 -0
  4. package/.claude/skills/release/SKILL.md +48 -0
  5. package/CHANGELOG.md +41 -0
  6. package/README.md +10 -3
  7. package/dist/cli/apply.js +1 -1
  8. package/dist/cli/apply.js.map +1 -1
  9. package/dist/cli/commands.js +2 -2
  10. package/dist/cli/commands.js.map +1 -1
  11. package/dist/cli/config-cmd.d.ts.map +1 -1
  12. package/dist/cli/config-cmd.js +2 -1
  13. package/dist/cli/config-cmd.js.map +1 -1
  14. package/dist/cli/init.d.ts.map +1 -1
  15. package/dist/cli/init.js +2 -1
  16. package/dist/cli/init.js.map +1 -1
  17. package/dist/cli/rename.d.ts.map +1 -1
  18. package/dist/cli/rename.js +47 -1
  19. package/dist/cli/rename.js.map +1 -1
  20. package/dist/cli/watch.js.map +1 -1
  21. package/dist/index.js +3 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/parsers/factory.d.ts +1 -1
  24. package/dist/parsers/factory.d.ts.map +1 -1
  25. package/dist/parsers/factory.js +1 -1
  26. package/dist/parsers/factory.js.map +1 -1
  27. package/dist/services/file-renamer.d.ts +3 -1
  28. package/dist/services/file-renamer.d.ts.map +1 -1
  29. package/dist/services/file-renamer.js +8 -3
  30. package/dist/services/file-renamer.js.map +1 -1
  31. package/dist/utils/ai-prompts.d.ts.map +1 -1
  32. package/dist/utils/ai-prompts.js +2 -0
  33. package/dist/utils/ai-prompts.js.map +1 -1
  34. package/dist/utils/config-loader.d.ts.map +1 -1
  35. package/dist/utils/config-loader.js +5 -1
  36. package/dist/utils/config-loader.js.map +1 -1
  37. package/dist/utils/file-templates.js +1 -1
  38. package/dist/utils/file-templates.js.map +1 -1
  39. package/dist/utils/image-compressor.d.ts +6 -0
  40. package/dist/utils/image-compressor.d.ts.map +1 -1
  41. package/dist/utils/image-compressor.js +23 -6
  42. package/dist/utils/image-compressor.js.map +1 -1
  43. package/dist/utils/pdf-to-image.d.ts.map +1 -1
  44. package/dist/utils/pdf-to-image.js +38 -18
  45. package/dist/utils/pdf-to-image.js.map +1 -1
  46. package/dist/utils/retry.d.ts +15 -0
  47. package/dist/utils/retry.d.ts.map +1 -0
  48. package/dist/utils/retry.js +26 -0
  49. package/dist/utils/retry.js.map +1 -0
  50. package/dist/utils/sanitizer.d.ts.map +1 -1
  51. package/dist/utils/sanitizer.js +1 -0
  52. package/dist/utils/sanitizer.js.map +1 -1
  53. package/eslint.config.js +27 -0
  54. package/package.json +11 -3
  55. package/scripts/generate-smoke-corpus.ts +138 -0
@@ -0,0 +1,15 @@
1
+ export interface RetryOptions {
2
+ /** Total attempts including the first call. Default 3. */
3
+ attempts?: number;
4
+ /** Delay before the first retry; doubles each subsequent retry. Default 1000ms. */
5
+ baseDelayMs?: number;
6
+ /** Injectable for tests. */
7
+ sleep?: (ms: number) => Promise<void>;
8
+ }
9
+ /**
10
+ * Retries transient AI-provider failures (rate limits, network/5xx errors)
11
+ * with exponential backoff. Everything else — auth, parse, programming
12
+ * errors — is rethrown immediately.
13
+ */
14
+ export declare function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
15
+ //# sourceMappingURL=retry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../src/utils/retry.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,YAAY;IAC3B,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4BAA4B;IAC5B,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,CAAC,CAAC,CAiB5F"}
@@ -0,0 +1,26 @@
1
+ import { RateLimitError, NetworkError } from '../errors.js';
2
+ /**
3
+ * Retries transient AI-provider failures (rate limits, network/5xx errors)
4
+ * with exponential backoff. Everything else — auth, parse, programming
5
+ * errors — is rethrown immediately.
6
+ */
7
+ export async function withRetry(fn, opts = {}) {
8
+ const attempts = opts.attempts ?? 3;
9
+ const base = opts.baseDelayMs ?? 1000;
10
+ const sleep = opts.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
11
+ let lastError;
12
+ for (let attempt = 1; attempt <= attempts; attempt++) {
13
+ try {
14
+ return await fn();
15
+ }
16
+ catch (error) {
17
+ lastError = error;
18
+ const retryable = error instanceof RateLimitError || error instanceof NetworkError;
19
+ if (!retryable || attempt === attempts)
20
+ throw error;
21
+ await sleep(base * 2 ** (attempt - 1));
22
+ }
23
+ }
24
+ throw lastError;
25
+ }
26
+ //# sourceMappingURL=retry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retry.js","sourceRoot":"","sources":["../../src/utils/retry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAW5D;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAI,EAAoB,EAAE,OAAqB,EAAE;IAC9E,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,CAAC,CAAC;IACpC,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC;IACtC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,IAAI,OAAO,CAAO,OAAO,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IAE1F,IAAI,SAAkB,CAAC;IACvB,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,QAAQ,EAAE,OAAO,EAAE,EAAE,CAAC;QACrD,IAAI,CAAC;YACH,OAAO,MAAM,EAAE,EAAE,CAAC;QACpB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,SAAS,GAAG,KAAK,CAAC;YAClB,MAAM,SAAS,GAAG,KAAK,YAAY,cAAc,IAAI,KAAK,YAAY,YAAY,CAAC;YACnF,IAAI,CAAC,SAAS,IAAI,OAAO,KAAK,QAAQ;gBAAE,MAAM,KAAK,CAAC;YACpD,MAAM,KAAK,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC;QACzC,CAAC;IACH,CAAC;IACD,MAAM,SAAS,CAAC;AAClB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"sanitizer.d.ts","sourceRoot":"","sources":["../../src/utils/sanitizer.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAErD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,GAAE,gBAA+B,GAAG,MAAM,CAOlG"}
1
+ {"version":3,"file":"sanitizer.d.ts","sourceRoot":"","sources":["../../src/utils/sanitizer.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAErD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,GAAE,gBAA+B,GAAG,MAAM,CAQlG"}
@@ -2,6 +2,7 @@ import { applyNamingConvention, stripWindowsIllegalChars } from './naming-conven
2
2
  export function sanitizeFilename(stem, convention = 'kebab-case') {
3
3
  let result = stem.normalize('NFC');
4
4
  result = stripWindowsIllegalChars(result);
5
+ // eslint-disable-next-line no-control-regex -- stripping control chars from filenames is the point
5
6
  result = result.replace(/[\x00-\x1F\x7F]/g, '');
6
7
  result = applyNamingConvention(result, convention);
7
8
  if (result.length > 200)
@@ -1 +1 @@
1
- {"version":3,"file":"sanitizer.js","sourceRoot":"","sources":["../../src/utils/sanitizer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,yBAAyB,CAAC;AAG1F,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,aAA+B,YAAY;IACxF,IAAI,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IACnC,MAAM,GAAG,wBAAwB,CAAC,MAAM,CAAC,CAAC;IAC1C,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAC;IAChD,MAAM,GAAG,qBAAqB,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACnD,IAAI,MAAM,CAAC,MAAM,GAAG,GAAG;QAAE,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACvD,OAAO,MAAM,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"sanitizer.js","sourceRoot":"","sources":["../../src/utils/sanitizer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,yBAAyB,CAAC;AAG1F,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,aAA+B,YAAY;IACxF,IAAI,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IACnC,MAAM,GAAG,wBAAwB,CAAC,MAAM,CAAC,CAAC;IAC1C,mGAAmG;IACnG,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAC;IAChD,MAAM,GAAG,qBAAqB,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACnD,IAAI,MAAM,CAAC,MAAM,GAAG,GAAG;QAAE,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACvD,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,27 @@
1
+ import js from '@eslint/js';
2
+ import tseslint from 'typescript-eslint';
3
+
4
+ export default tseslint.config(
5
+ { ignores: ['dist/', 'coverage/', 'node_modules/'] },
6
+ js.configs.recommended,
7
+ ...tseslint.configs.recommended,
8
+ {
9
+ rules: {
10
+ // CLI tool: interfaces accept broad option bags; explicit any is a
11
+ // deliberate escape hatch, unused args prefixed with _ are intentional
12
+ '@typescript-eslint/no-explicit-any': 'off',
13
+ '@typescript-eslint/no-unused-vars': [
14
+ 'error',
15
+ { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }
16
+ ]
17
+ }
18
+ },
19
+ {
20
+ files: ['tests/**'],
21
+ rules: {
22
+ '@typescript-eslint/no-require-imports': 'off',
23
+ // mocks legitimately wrap arbitrary callbacks
24
+ '@typescript-eslint/no-unsafe-function-type': 'off'
25
+ }
26
+ }
27
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amirdaraee/namewise",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,9 @@
17
17
  "test:watch": "vitest --watch",
18
18
  "test:unit": "vitest run tests/unit",
19
19
  "test:integration": "vitest run tests/integration",
20
+ "lint": "eslint .",
21
+ "smoke:generate": "tsx scripts/generate-smoke-corpus.ts",
22
+ "smoke": "npm run build && npm run smoke:generate && node dist/index.js rename smoke-corpus --dry-run",
20
23
  "version:patch": "npm version patch",
21
24
  "version:minor": "npm version minor",
22
25
  "version:major": "npm version major",
@@ -53,6 +56,7 @@
53
56
  "license": "MIT",
54
57
  "description": "AI-powered CLI tool that intelligently renames files based on their content using Claude or OpenAI",
55
58
  "devDependencies": {
59
+ "@eslint/js": "10.0.1",
56
60
  "@types/fs-extra": "^11.0.4",
57
61
  "@types/heic-convert": "2.1.0",
58
62
  "@types/inquirer": "^9.0.9",
@@ -60,14 +64,15 @@
60
64
  "@types/pdfkit": "0.17.5",
61
65
  "@vitest/coverage-v8": "^3.2.4",
62
66
  "@vitest/ui": "^3.2.4",
67
+ "eslint": "10.4.1",
63
68
  "pdfkit": "0.8.3",
64
69
  "tsx": "4.21.0",
65
70
  "typescript": "^5.9.2",
71
+ "typescript-eslint": "8.61.0",
66
72
  "vitest": "^3.2.4"
67
73
  },
68
74
  "dependencies": {
69
75
  "@anthropic-ai/sdk": "^0.61.0",
70
- "canvas": "3.2.3",
71
76
  "chalk": "5.6.2",
72
77
  "chokidar": "5.0.0",
73
78
  "commander": "14.0.3",
@@ -80,7 +85,10 @@
80
85
  "openai": "^5.19.1",
81
86
  "ora": "9.3.0",
82
87
  "pdf-extraction": "^1.0.2",
83
- "pdf-to-png-converter": "3.14.0",
84
88
  "pdfjs-dist": "5.4.149"
89
+ },
90
+ "optionalDependencies": {
91
+ "canvas": "3.2.3",
92
+ "pdf-to-png-converter": "3.14.0"
85
93
  }
86
94
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Smoke-test corpus generator — manual pre-release quality check, NOT CI.
3
+ *
4
+ * Generates ~10 deliberately messy files in ./smoke-corpus/ that mimic what
5
+ * real users point namewise at: scanner default names, "Untitled-final(2)"
6
+ * documents, Windows "New Text Document.txt", etc.
7
+ *
8
+ * Usage:
9
+ * npm run smoke:generate # just (re)create the corpus
10
+ * npm run smoke # build + generate + dry-run rename with
11
+ * # your configured provider/API key
12
+ *
13
+ * Eyeball the suggested names: are they descriptive, correctly categorized,
14
+ * and in the right language? This doubles as the prompt-tuning feedback loop.
15
+ */
16
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
17
+ import path from 'path';
18
+ import Excel from 'exceljs';
19
+ import PDFDocument from 'pdfkit';
20
+
21
+ const CORPUS_DIR = path.join(process.cwd(), 'smoke-corpus');
22
+
23
+ function createPdfBuffer(write: (doc: InstanceType<typeof PDFDocument>) => void): Promise<Buffer> {
24
+ return new Promise<Buffer>((resolve, reject) => {
25
+ const doc = new PDFDocument({ size: 'A4', autoFirstPage: true });
26
+ const chunks: Buffer[] = [];
27
+ doc.on('data', (chunk: Buffer) => chunks.push(chunk));
28
+ doc.on('end', () => {
29
+ const pdfBuf = Buffer.concat(chunks);
30
+ // pdfjs v1.x misreads small pooled Buffers (byteOffset > 0); padding past
31
+ // 4096 bytes forces a dedicated allocation — same trick as the test fixtures.
32
+ if (pdfBuf.length > 4096) {
33
+ resolve(pdfBuf);
34
+ } else {
35
+ const pad = Buffer.alloc(4097 - pdfBuf.length, 0x20);
36
+ resolve(Buffer.concat([pdfBuf, Buffer.from('\n%padded\n'), pad]));
37
+ }
38
+ });
39
+ doc.on('error', reject);
40
+ write(doc);
41
+ doc.end();
42
+ });
43
+ }
44
+
45
+ async function main(): Promise<void> {
46
+ if (existsSync(CORPUS_DIR)) rmSync(CORPUS_DIR, { recursive: true });
47
+ mkdirSync(CORPUS_DIR, { recursive: true });
48
+
49
+ // 1. Scanner default name, real text content (invoice)
50
+ writeFileSync(
51
+ path.join(CORPUS_DIR, 'scan0001.pdf'),
52
+ await createPdfBuffer(doc => {
53
+ doc.fontSize(18).text('INVOICE #2024-0312');
54
+ doc.fontSize(11).text('\nBilled to: Riverside Dental Clinic\nDate: March 12, 2024\n\nDescription: Annual website maintenance and hosting\nAmount due: $1,450.00\nPayment due within 30 days.');
55
+ })
56
+ );
57
+
58
+ // 2. The classic "final final" document (meeting notes)
59
+ writeFileSync(
60
+ path.join(CORPUS_DIR, 'Untitled-final(2).pdf'),
61
+ await createPdfBuffer(doc => {
62
+ doc.fontSize(14).text('Product Team Meeting Notes');
63
+ doc.fontSize(11).text('\nDate: June 3, 2026\nAttendees: Sara, Mike, Priya\n\nDecisions:\n- Ship onboarding redesign in July\n- Defer mobile dark mode to Q4\n- Hire one more backend engineer');
64
+ })
65
+ );
66
+
67
+ // 3. Image-only PDF (no selectable text) — exercises the scanned-PDF vision path
68
+ writeFileSync(
69
+ path.join(CORPUS_DIR, 'Scan_2023_11_30.pdf'),
70
+ await createPdfBuffer(doc => {
71
+ doc.rect(0, 0, doc.page.width, doc.page.height).fill('white');
72
+ })
73
+ );
74
+
75
+ // 4. Windows default name, recipe content
76
+ writeFileSync(
77
+ path.join(CORPUS_DIR, 'New Text Document.txt'),
78
+ 'Grandma\'s lemon poppy seed muffins\n\nIngredients: 2 cups flour, 3/4 cup sugar, 2 tbsp poppy seeds, zest of 2 lemons, 1 cup buttermilk, 2 eggs.\nBake at 190C for 18-20 minutes. Makes 12 muffins.\n'
79
+ );
80
+
81
+ // 5. Keyboard-mash name, project README content
82
+ writeFileSync(
83
+ path.join(CORPUS_DIR, 'asdf.md'),
84
+ '# Inventory Sync Service\n\nSyncs warehouse inventory counts between Shopify and the internal ERP every 15 minutes.\n\n## Setup\n\n```bash\nnpm install && npm start\n```\n'
85
+ );
86
+
87
+ // 6. Vague sequential name, insurance letter
88
+ writeFileSync(
89
+ path.join(CORPUS_DIR, 'document1.txt'),
90
+ 'Dear Policyholder,\n\nThis letter confirms the renewal of your home insurance policy HO-2291-B effective January 1, 2026. Your annual premium is $1,184. Coverage includes fire, theft, and water damage up to $450,000.\n\nNorthstar Insurance Group\n'
91
+ );
92
+
93
+ // 7. "(copy)" suffix, travel itinerary
94
+ writeFileSync(
95
+ path.join(CORPUS_DIR, 'notes (copy).md'),
96
+ '# Lisbon Trip\n\n- Flight TP 942, departs May 14 08:25\n- Hotel: Casa do Bairro, Alfama (3 nights)\n- Day 2: Sintra day trip, Pena Palace tickets booked\n- Day 3: Time Out Market, Fado show 21:00\n'
97
+ );
98
+
99
+ // 8. The infamous final-FINAL, CSV-ish sales data
100
+ writeFileSync(
101
+ path.join(CORPUS_DIR, 'data-export-final-FINAL.txt'),
102
+ 'region,month,units,revenue\nNorth,2026-01,412,20600\nNorth,2026-02,498,24900\nSouth,2026-01,377,18850\nSouth,2026-02,401,20050\n'
103
+ );
104
+
105
+ // 9. Excel default workbook name, household budget
106
+ const workbook = new Excel.Workbook();
107
+ const sheet = workbook.addWorksheet('Budget');
108
+ sheet.addRow(['Category', 'Monthly budget', 'Actual']);
109
+ sheet.addRow(['Rent', 2100, 2100]);
110
+ sheet.addRow(['Groceries', 600, 684]);
111
+ sheet.addRow(['Transport', 150, 121]);
112
+ sheet.addRow(['Savings', 800, 800]);
113
+ await workbook.xlsx.writeFile(path.join(CORPUS_DIR, 'Book1.xlsx'));
114
+
115
+ // 10. Camera default name, image with drawn text (needs optional canvas)
116
+ try {
117
+ const { createCanvas } = await import('canvas');
118
+ const canvas = createCanvas(800, 500);
119
+ const ctx = canvas.getContext('2d');
120
+ ctx.fillStyle = '#f4e8d0';
121
+ ctx.fillRect(0, 0, 800, 500);
122
+ ctx.fillStyle = '#222';
123
+ ctx.font = 'bold 42px sans-serif';
124
+ ctx.fillText('FARMERS MARKET', 180, 200);
125
+ ctx.font = '28px sans-serif';
126
+ ctx.fillText('Every Saturday 8am - 1pm, Oak Street Square', 90, 280);
127
+ writeFileSync(path.join(CORPUS_DIR, 'IMG_20240315_0042.jpg'), canvas.toBuffer('image/jpeg'));
128
+ } catch {
129
+ console.log(' (canvas not installed — skipping the image fixture)');
130
+ }
131
+
132
+ console.log(`Smoke corpus written to ${CORPUS_DIR}`);
133
+ }
134
+
135
+ main().catch(err => {
136
+ console.error(err);
137
+ process.exit(1);
138
+ });