diff --git a/README.md b/README.md index 746bbda..29d5351 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,10 @@ expect the tool to be installed globally, but they should work by replacing ### Usage -Next you'll need to decide whether to use `openai`, `gemini` or `local` mode. In a +Next you'll need to decide whether to use `openai`, `gemini`, `anthropic` or `local` mode. In a nutshell: -* `openai` or `gemini` mode +* `openai`, `gemini` or `anthropic` mode * Runs on someone else's computer that's specifically optimized for this kind of things * Costs money depending on the length of your code @@ -135,6 +135,20 @@ humanify gemini --apiKey="your-token" obfuscated-file.js Alternatively you can also use an environment variable `GEMINI_API_KEY`. Use `humanify --help` to see all available options. +### Anthropic mode + +You'll need an Anthropic key. You can get one by signing up at +https://console.anthropic.com. + +You need to provice the API key to the tool: + +```shell +humanify anthropic --apiKey="your-token" obfuscated-file.js +``` + +Alternatively you can also use an environment variable `ANTHROPIC_API_KEY`. Use +`humanify --help` to see all available options. + ### Local mode The local mode uses a pre-trained language model to deobfuscate the code. The diff --git a/package-lock.json b/package-lock.json index d9f5e06..3e42995 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.2.2", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.33.1", "@babel/core": "^7.25.2", "@babel/types": "^7.25.2", "@google/generative-ai": "^0.20.0", @@ -50,6 +51,33 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.33.1.tgz", + "integrity": "sha512-VrlbxiAdVRGuKP2UQlCnsShDHJKWepzvfRCkZMpU+oaUdKLpOfmylLMRojGrAgebV+kDtPjewCVP0laHXg+vsA==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.70", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.70.tgz", + "integrity": "sha512-RE+K0+KZoEpDUbGGctnGdkrLFwi1eYKTlIHNl2Um98mUkGsm1u2Ff6Ltd0e8DktTtC98uy7rSj+hO8t/QuLoVQ==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, "node_modules/@babel/code-frame": { "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.0.tgz", diff --git a/package.json b/package.json index f5c22f2..6275763 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "author": "Jesse Luoto", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.33.1", "@babel/core": "^7.25.2", "@babel/types": "^7.25.2", "@google/generative-ai": "^0.20.0", diff --git a/src/commands/anthropic.ts b/src/commands/anthropic.ts new file mode 100644 index 0000000..cb73b0d --- /dev/null +++ b/src/commands/anthropic.ts @@ -0,0 +1,50 @@ +import { cli } from "../cli.js"; +import prettier from "../plugins/prettier.js"; +import { unminify } from "../unminify.js"; +import babel from "../plugins/babel/babel.js"; +import { verbose } from "../verbose.js"; +import { anthropicRename } from "../plugins/anthropic-rename.js"; +import { env } from "../env.js"; +import { parseNumber } from "../number-utils.js"; +import { DEFAULT_CONTEXT_WINDOW_SIZE } from "./default-args.js"; + +export const anthropic = cli() + .name("anthropic") + .description("Use Anthropic's Claude API to unminify code") + .option("-m, --model ", "The model to use", "claude-3-sonnet-20240229") + .option("-o, --outputDir ", "The output directory", "output") + .option( + "-k, --apiKey ", + "The Anthropic API key. Alternatively use ANTHROPIC_API_KEY environment variable" + ) + .option( + "--baseURL ", + "The Anthropic base server URL.", + env("ANTHROPIC_BASE_URL") ?? "https://api.anthropic.com" + ) + .option("--verbose", "Show verbose output") + .option( + "--contextSize ", + "The context size to use for the LLM", + `${DEFAULT_CONTEXT_WINDOW_SIZE}` + ) + .argument("input", "The input minified Javascript file") + .action(async (filename, opts) => { + if (opts.verbose) { + verbose.enabled = true; + } + const apiKey = opts.apiKey ?? env("ANTHROPIC_API_KEY"); + const baseURL = opts.baseURL; + const contextWindowSize = parseNumber(opts.contextSize); + + await unminify(filename, opts.outputDir, [ + babel, + anthropicRename({ + apiKey, + baseURL, + model: opts.model, + contextWindowSize + }), + prettier + ]); + }); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index d14a0d5..2a10780 100755 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { local } from "./commands/local.js"; import { openai } from "./commands/openai.js"; import { cli } from "./cli.js"; import { azure } from "./commands/gemini.js"; +import { anthropic } from "./commands/anthropic.js"; cli() .name("humanify") @@ -13,5 +14,6 @@ cli() .addCommand(local) .addCommand(openai) .addCommand(azure) + .addCommand(anthropic) .addCommand(download()) .parse(process.argv); diff --git a/src/plugins/anthropic-rename.ts b/src/plugins/anthropic-rename.ts new file mode 100644 index 0000000..1e02b32 --- /dev/null +++ b/src/plugins/anthropic-rename.ts @@ -0,0 +1,85 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { visitAllIdentifiers } from "./local-llm-rename/visit-all-identifiers.js"; +import { showPercentage } from "../progress.js"; +import { verbose } from "../verbose.js"; + +export function anthropicRename({ + apiKey, + baseURL, + model, + contextWindowSize +}: { + apiKey: string; + baseURL?: string; + model: string; + contextWindowSize: number; +}) { + const client = new Anthropic({ + apiKey, + baseURL + }); + + return async (code: string): Promise => { + return await visitAllIdentifiers( + code, + async (name, surroundingCode) => { + verbose.log(`Renaming ${name}`); + verbose.log("Context: ", surroundingCode); + + const response = await client.messages.create( + toRenamePrompt(name, surroundingCode, model, contextWindowSize) + ); + + const result = response.content[0]; + if (!result) { + throw new Error('Failed to rename', { cause: response }); + } + const renamed = result.input.newName + verbose.log(`${name} renamed to ${renamed}`); + return renamed; + }, + contextWindowSize, + showPercentage + ); + }; +} + +function toRenamePrompt( + name: string, + surroundingCode: string, + model: string, + contextWindowSize: number, +): Anthropic.Messages.MessageCreateParams { + return { + model, + messages: [ + { + role: "user", + content: `Analyze this code and suggest a descriptive name for the variable/function \`${name}\`: + ${surroundingCode}` + } + ], + max_tokens: contextWindowSize, + tools: [ + { + name: "suggest_name", + description: "Suggest a descriptive name for the code element", + input_schema: { + type: "object", + properties: { + newName: { + type: "string", + description: `The new descriptive name for the variable/function called \`${name}\`` + } + }, + required: ["newName"], + additionalProperties: false + } + } + ], + tool_choice: { + type: "tool", + name: "suggest_name" + } + }; +} \ No newline at end of file diff --git a/src/test/e2e.anthropictest.ts b/src/test/e2e.anthropictest.ts new file mode 100644 index 0000000..d6608e0 --- /dev/null +++ b/src/test/e2e.anthropictest.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import { readFile, rm } from "node:fs/promises"; +import { testPrompt } from "./test-prompt.js"; +import { gbnf } from "../plugins/local-llm-rename/gbnf.js"; +import assert from "node:assert"; +import { humanify } from "../test-utils.js"; + +const TEST_OUTPUT_DIR = "test-output"; + +test.afterEach(async () => { + await rm(TEST_OUTPUT_DIR, { recursive: true, force: true }); +}); + +test("Unminifies an example file successfully", async () => { + const fileIsMinified = async (filename: string) => { + const prompt = await testPrompt(); + return await prompt( + `Your job is to read the following code and rate it's readabillity and variable names. Answer "EXCELLENT", "GOOD" or "UNREADABLE".`, + await readFile(filename, "utf-8"), + gbnf`${/("EXCELLENT" | "GOOD" | "UNREADABLE") [^.]+/}.` + ); + }; + const expectStartsWith = (expected: string[], actual: string) => { + assert( + expected.some((e) => actual.startsWith(e)), + `Expected "${expected}" but got ${actual}` + ); + }; + + await expectStartsWith( + ["UNREADABLE"], + await fileIsMinified(`fixtures/example.min.js`) + ); + + await humanify( + "anthropic", + "fixtures/example.min.js", + "--verbose", + "--outputDir", + TEST_OUTPUT_DIR + ); + + await expectStartsWith( + ["EXCELLENT", "GOOD"], + await fileIsMinified(`${TEST_OUTPUT_DIR}/deobfuscated.js`) + ); +});