@ast-ai-model-router/cli 0.1.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.
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "ast-ai-model-router",
3
+ "version": "0.1.0",
4
+ "description": "AST-aware Claude and Codex model routing for cost-conscious coding tasks.",
5
+ "author": {
6
+ "name": "Faraazuddin Mohammed",
7
+ "email": "opensource@faraa2m.dev",
8
+ "url": "https://github.com/faraa2m"
9
+ },
10
+ "homepage": "https://github.com/faraa2m/ast-ai-model-router#readme",
11
+ "repository": "https://github.com/faraa2m/ast-ai-model-router",
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "token-economics",
15
+ "model-router",
16
+ "claude-code",
17
+ "codex",
18
+ "ast"
19
+ ],
20
+ "skills": "./skills/"
21
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "ast-ai-model-router",
3
+ "version": "0.1.0",
4
+ "description": "AST-aware Claude and Codex model routing for cost-conscious coding tasks.",
5
+ "author": {
6
+ "name": "Faraazuddin Mohammed",
7
+ "email": "opensource@faraa2m.dev",
8
+ "url": "https://github.com/faraa2m"
9
+ },
10
+ "homepage": "https://github.com/faraa2m/ast-ai-model-router#readme",
11
+ "repository": "https://github.com/faraa2m/ast-ai-model-router",
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "token-economics",
15
+ "model-router",
16
+ "claude-code",
17
+ "codex",
18
+ "ast"
19
+ ],
20
+ "skills": "./skills/",
21
+ "interface": {
22
+ "displayName": "AST AI Model Router",
23
+ "shortDescription": "Pick Claude or Codex models from AST and task complexity signals.",
24
+ "longDescription": "AST AI Model Router analyzes JavaScript, TypeScript, and Python project structure plus the current task to choose an appropriate Claude or Codex model before launch. It is part of a token-economics toolkit for measuring, reducing, and routing LLM spend.",
25
+ "developerName": "Faraazuddin Mohammed",
26
+ "category": "Developer Tools",
27
+ "capabilities": [
28
+ "Interactive",
29
+ "Local"
30
+ ],
31
+ "websiteURL": "https://github.com/faraa2m/ast-ai-model-router",
32
+ "privacyPolicyURL": "https://github.com/faraa2m/ast-ai-model-router#privacy",
33
+ "termsOfServiceURL": "https://github.com/faraa2m/ast-ai-model-router#license",
34
+ "defaultPrompt": [
35
+ "Analyze this repo and choose the right Codex model.",
36
+ "Route this Claude Code task by AST complexity.",
37
+ "Explain why this coding task needs a stronger model."
38
+ ],
39
+ "brandColor": "#2563EB"
40
+ }
41
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faraazuddin Mohammed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # AST AI Model Router
2
+
3
+ AST-aware model selection for Claude Code and Codex. It inspects the current task, JavaScript/TypeScript ASTs, Python ASTs, and repo shape, then launches the coding agent with a dynamically chosen model.
4
+
5
+ This project is part of the [`faraa2m`](https://github.com/faraa2m) token economics stack:
6
+
7
+ - [`tokenometer`](https://github.com/faraa2m/tokenometer) measures tokens, USD cost, latency, and CI prompt-cost regressions.
8
+ - [`llm-tokens-atlas`](https://github.com/faraa2m/llm-tokens-atlas) calibrates offline tokenizers against empirical provider counts.
9
+ - [`routerlab`](https://github.com/faraa2m/routerlab) builds cost-quality routing frontiers for LLM APIs.
10
+ - [`promptc`](https://github.com/faraa2m/promptc) compiles prompts through deterministic cost-reduction passes.
11
+ - `ast-ai-model-router` brings that cost-aware routing idea into local coding agents.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -g @ast-ai-model-router/cli
17
+ ```
18
+
19
+ For local development:
20
+
21
+ ```bash
22
+ npm install
23
+ npm link
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ Analyze a task without launching an agent:
29
+
30
+ ```bash
31
+ ast-ai-model-router analyze --agent codex --task "refactor the auth parser and add tests"
32
+ ```
33
+
34
+ Launch Codex with the selected model:
35
+
36
+ ```bash
37
+ ast-ai-model-router run codex --task "fix the failing Python AST tests" -- --cd .
38
+ ```
39
+
40
+ Launch Claude Code with the selected alias:
41
+
42
+ ```bash
43
+ ast-ai-model-router run claude --task "plan a cross-module migration" -- --permission-mode plan
44
+ ```
45
+
46
+ Machine-readable output:
47
+
48
+ ```bash
49
+ ast-ai-model-router analyze --agent codex --task "write docs" --json
50
+ ```
51
+
52
+ ## How Routing Works
53
+
54
+ The router scores four groups of signals:
55
+
56
+ - Prompt intent: docs, tests, debugging, refactors, architecture, security, migrations.
57
+ - Repo shape: file count, AST file count, package/build/config files.
58
+ - AST complexity: functions, classes, branches, imports, and language mix.
59
+ - Agent model catalog: Codex models are discovered through `codex debug models`; Claude uses dynamic aliases.
60
+
61
+ Claude targets are intentionally aliases, not dated model names:
62
+
63
+ - `simple` -> `haiku`
64
+ - `balanced` -> `sonnet`
65
+ - `complex` -> `opus`
66
+ - `planning` -> `opusplan`
67
+
68
+ Codex targets are selected from the installed Codex model catalog. If discovery fails, the router falls back to configurable defaults in `model-router.config.json`.
69
+
70
+ ## Configuration
71
+
72
+ Add `model-router.config.json` to a project root:
73
+
74
+ ```json
75
+ {
76
+ "thresholds": {
77
+ "simpleMax": 34,
78
+ "balancedMax": 74
79
+ },
80
+ "claude": {
81
+ "aliases": {
82
+ "simple": "haiku",
83
+ "balanced": "sonnet",
84
+ "complex": "opus",
85
+ "planning": "opusplan"
86
+ }
87
+ },
88
+ "codex": {
89
+ "discoveryCommand": "codex debug models"
90
+ }
91
+ }
92
+ ```
93
+
94
+ ## Plugin
95
+
96
+ This repo includes a Codex/Claude plugin manifest at `.codex-plugin/plugin.json` and a skill under `skills/model-router/`.
97
+
98
+ Use it directly while developing:
99
+
100
+ ```bash
101
+ claude --plugin-dir .
102
+ codex plugin marketplace add .
103
+ ```
104
+
105
+ ## Privacy
106
+
107
+ The router reads local source files to compute AST complexity and launches the local `claude` or `codex` CLI. It does not add a separate network service. Any model traffic comes from the Claude/Codex CLI you choose to run.
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { parseArgs } from "node:util";
4
+ import { analyzeTask } from "../lib/analyzer.js";
5
+ import { loadConfig } from "../lib/config.js";
6
+ import { chooseModel } from "../lib/models.js";
7
+
8
+ const HELP = `ast-ai-model-router
9
+
10
+ Usage:
11
+ ast-ai-model-router analyze --agent claude|codex --task "fix auth bug" [--json]
12
+ ast-ai-model-router run claude --task "refactor parser" -- [extra claude args]
13
+ ast-ai-model-router run codex --task "write tests" -- [extra codex args]
14
+
15
+ Options:
16
+ --agent <agent> claude or codex for analyze
17
+ --task <text> Current task description
18
+ --cwd <path> Workspace to inspect, defaults to current directory
19
+ --json Emit machine-readable JSON
20
+ --refresh-models Refresh Codex model catalog cache
21
+ `;
22
+
23
+ async function main() {
24
+ const [command, maybeAgent, ...rest] = process.argv.slice(2);
25
+ if (!command || command === "--help" || command === "-h") {
26
+ process.stdout.write(HELP);
27
+ return;
28
+ }
29
+
30
+ if (command === "analyze") {
31
+ const { values } = parseArgs({
32
+ args: [maybeAgent, ...rest].filter(Boolean),
33
+ options: {
34
+ agent: { type: "string" },
35
+ task: { type: "string" },
36
+ cwd: { type: "string" },
37
+ json: { type: "boolean" },
38
+ "refresh-models": { type: "boolean" }
39
+ }
40
+ });
41
+ const agent = assertAgent(values.agent);
42
+ const result = await route({ agent, task: values.task, cwd: values.cwd, refreshModels: values["refresh-models"] });
43
+ printResult(result, Boolean(values.json));
44
+ return;
45
+ }
46
+
47
+ if (command === "run") {
48
+ const agent = assertAgent(maybeAgent);
49
+ const split = rest.indexOf("--");
50
+ const optionArgs = split === -1 ? rest : rest.slice(0, split);
51
+ const passthrough = split === -1 ? [] : rest.slice(split + 1);
52
+ const { values } = parseArgs({
53
+ args: optionArgs,
54
+ options: {
55
+ task: { type: "string" },
56
+ cwd: { type: "string" },
57
+ "refresh-models": { type: "boolean" }
58
+ }
59
+ });
60
+ const result = await route({ agent, task: values.task, cwd: values.cwd, refreshModels: values["refresh-models"] });
61
+ const executable = agent === "claude" ? "claude" : "codex";
62
+ const args = ["--model", result.selectedModel, ...passthrough];
63
+ process.stderr.write(`[model-router] ${agent}: ${result.selectedModel} (${result.tier}, confidence ${result.confidence.toFixed(2)})\n`);
64
+ const child = spawn(executable, args, { cwd: result.cwd, stdio: "inherit" });
65
+ child.on("exit", (code, signal) => {
66
+ if (signal) {
67
+ process.kill(process.pid, signal);
68
+ return;
69
+ }
70
+ process.exit(code ?? 1);
71
+ });
72
+ child.on("error", (error) => {
73
+ process.stderr.write(`[model-router] failed to launch ${executable}: ${error.message}\n`);
74
+ process.exit(1);
75
+ });
76
+ return;
77
+ }
78
+
79
+ throw new Error(`Unknown command: ${command}`);
80
+ }
81
+
82
+ async function route({ agent, task, cwd, refreshModels }) {
83
+ const config = await loadConfig(cwd ?? process.cwd());
84
+ const analysis = await analyzeTask({ cwd: cwd ?? process.cwd(), task: task ?? "", config });
85
+ const model = await chooseModel({ agent, tier: analysis.tier, task: task ?? "", config, refreshModels: Boolean(refreshModels) });
86
+ return {
87
+ agent,
88
+ cwd: analysis.cwd,
89
+ selectedModel: model.model,
90
+ tier: analysis.tier,
91
+ confidence: analysis.confidence,
92
+ signals: analysis.signals,
93
+ modelSource: model.source,
94
+ commandPreview: `${agent} --model ${model.model}`
95
+ };
96
+ }
97
+
98
+ function assertAgent(agent) {
99
+ if (agent === "claude" || agent === "codex") return agent;
100
+ throw new Error("Expected agent to be 'claude' or 'codex'.");
101
+ }
102
+
103
+ function printResult(result, asJson) {
104
+ if (asJson) {
105
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
106
+ return;
107
+ }
108
+ process.stdout.write(`Agent: ${result.agent}\n`);
109
+ process.stdout.write(`Model: ${result.selectedModel}\n`);
110
+ process.stdout.write(`Tier: ${result.tier}\n`);
111
+ process.stdout.write(`Confidence: ${result.confidence.toFixed(2)}\n`);
112
+ process.stdout.write(`Signals: ${result.signals.map((signal) => `${signal.name}=${signal.value}`).join(", ")}\n`);
113
+ process.stdout.write(`Run: ${result.commandPreview}\n`);
114
+ }
115
+
116
+ main().catch((error) => {
117
+ process.stderr.write(`[model-router] ${error.message}\n`);
118
+ process.exit(1);
119
+ });
@@ -0,0 +1,113 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { analyzeJavaScriptFile } from "./parsers/javascript.js";
4
+ import { analyzePythonFile } from "./parsers/python.js";
5
+
6
+ const IGNORE_DIRS = new Set([".git", "node_modules", "dist", "build", ".next", ".venv", "venv", "__pycache__", "coverage"]);
7
+ const JS_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]);
8
+ const PY_EXTENSIONS = new Set([".py"]);
9
+
10
+ export async function analyzeTask({ cwd, task, config }) {
11
+ const root = path.resolve(cwd);
12
+ const files = await collectFiles(root);
13
+ const astFiles = files.filter((file) => JS_EXTENSIONS.has(path.extname(file)) || PY_EXTENSIONS.has(path.extname(file))).slice(0, 80);
14
+ const ast = await summarizeAst(astFiles);
15
+ const prompt = scorePrompt(task);
16
+ const repo = scoreRepo(files, astFiles);
17
+ const score = prompt.score + repo.score + ast.score;
18
+ const tier = tierFor(score, task, config);
19
+ const signals = [
20
+ { name: "prompt", value: prompt.score, detail: prompt.matches.join(",") || "none" },
21
+ { name: "repo", value: repo.score, detail: `${files.length} files, ${astFiles.length} AST files` },
22
+ { name: "ast", value: ast.score, detail: `${ast.functions} functions, ${ast.classes} classes, ${ast.branches} branches` },
23
+ { name: "languages", value: ast.languages.size, detail: [...ast.languages].join(",") || "none" }
24
+ ];
25
+ return {
26
+ cwd: root,
27
+ tier,
28
+ confidence: confidenceFor(score, tier, config),
29
+ score,
30
+ signals
31
+ };
32
+ }
33
+
34
+ async function collectFiles(root, relative = "") {
35
+ let entries;
36
+ try {
37
+ entries = await readdir(path.join(root, relative), { withFileTypes: true });
38
+ } catch {
39
+ return [];
40
+ }
41
+ const files = [];
42
+ for (const entry of entries) {
43
+ if (entry.name.startsWith(".") && entry.name !== ".github") continue;
44
+ const child = path.join(relative, entry.name);
45
+ if (entry.isDirectory()) {
46
+ if (!IGNORE_DIRS.has(entry.name)) files.push(...await collectFiles(root, child));
47
+ continue;
48
+ }
49
+ if (entry.isFile()) files.push(path.join(root, child));
50
+ }
51
+ return files;
52
+ }
53
+
54
+ async function summarizeAst(files) {
55
+ const total = { score: 0, functions: 0, classes: 0, branches: 0, imports: 0, languages: new Set() };
56
+ for (const file of files) {
57
+ const ext = path.extname(file);
58
+ const summary = PY_EXTENSIONS.has(ext) ? await analyzePythonFile(file) : await analyzeJavaScriptFile(file);
59
+ if (!summary.ok) continue;
60
+ total.functions += summary.functions;
61
+ total.classes += summary.classes;
62
+ total.branches += summary.branches;
63
+ total.imports += summary.imports;
64
+ total.languages.add(summary.language);
65
+ }
66
+ total.score = Math.min(45, total.functions * 0.45 + total.classes * 1.5 + total.branches * 0.7 + total.imports * 0.15 + total.languages.size * 4);
67
+ return total;
68
+ }
69
+
70
+ function scorePrompt(task) {
71
+ const text = task.toLowerCase();
72
+ const patterns = [
73
+ [/architecture|design|migration|refactor|rewrite|multi-file|cross-module/g, 18],
74
+ [/security|auth|database|billing|payment|production|deploy/g, 16],
75
+ [/debug|bug|failing|regression|race|performance/g, 12],
76
+ [/test|coverage|unit|integration|e2e/g, 8],
77
+ [/explain|summarize|readme|docs|comment/g, 4]
78
+ ];
79
+ const matches = [];
80
+ let score = 0;
81
+ for (const [regex, weight] of patterns) {
82
+ const found = text.match(regex);
83
+ if (!found) continue;
84
+ matches.push(...found);
85
+ score += Math.min(weight, found.length * weight);
86
+ }
87
+ if (text.length > 800) score += 10;
88
+ if (text.length > 1800) score += 10;
89
+ return { score: Math.min(50, score), matches };
90
+ }
91
+
92
+ function scoreRepo(files, astFiles) {
93
+ let score = 0;
94
+ if (files.length > 100) score += 8;
95
+ if (files.length > 500) score += 12;
96
+ if (astFiles.length > 20) score += 8;
97
+ if (astFiles.length > 60) score += 12;
98
+ if (files.some((file) => /package\.json|pyproject\.toml|requirements\.txt|Dockerfile|\.github\/workflows/.test(file))) score += 8;
99
+ return { score: Math.min(35, score) };
100
+ }
101
+
102
+ function tierFor(score, task, config) {
103
+ if (/plan|architecture|migration|strategy/i.test(task) && score >= config.thresholds.simpleMax) return "planning";
104
+ if (score <= config.thresholds.simpleMax) return "simple";
105
+ if (score <= config.thresholds.balancedMax) return "balanced";
106
+ return "complex";
107
+ }
108
+
109
+ function confidenceFor(score, tier, config) {
110
+ const target = tier === "simple" ? config.thresholds.simpleMax : tier === "balanced" ? config.thresholds.balancedMax : config.thresholds.balancedMax + 30;
111
+ const distance = Math.min(40, Math.abs(score - target));
112
+ return Math.max(0.55, Math.min(0.95, 0.95 - (40 - distance) / 120));
113
+ }
package/lib/config.js ADDED
@@ -0,0 +1,57 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export const DEFAULT_CONFIG = {
5
+ thresholds: {
6
+ simpleMax: 34,
7
+ balancedMax: 74
8
+ },
9
+ claude: {
10
+ aliases: {
11
+ simple: "haiku",
12
+ balanced: "sonnet",
13
+ complex: "opus",
14
+ planning: "opusplan"
15
+ }
16
+ },
17
+ codex: {
18
+ discoveryCommand: "codex debug models",
19
+ fallbackModels: {
20
+ simple: "gpt-5.4-mini",
21
+ balanced: "gpt-5.4",
22
+ complex: "gpt-5.5",
23
+ planning: "gpt-5.5"
24
+ }
25
+ },
26
+ overrides: []
27
+ };
28
+
29
+ export async function loadConfig(cwd) {
30
+ const configPath = path.join(cwd, "model-router.config.json");
31
+ try {
32
+ const raw = await readFile(configPath, "utf8");
33
+ return mergeConfig(DEFAULT_CONFIG, JSON.parse(raw));
34
+ } catch (error) {
35
+ if (error.code === "ENOENT") return DEFAULT_CONFIG;
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ function mergeConfig(base, override) {
41
+ return {
42
+ ...base,
43
+ ...override,
44
+ thresholds: { ...base.thresholds, ...override.thresholds },
45
+ claude: {
46
+ ...base.claude,
47
+ ...override.claude,
48
+ aliases: { ...base.claude.aliases, ...override.claude?.aliases }
49
+ },
50
+ codex: {
51
+ ...base.codex,
52
+ ...override.codex,
53
+ fallbackModels: { ...base.codex.fallbackModels, ...override.codex?.fallbackModels }
54
+ },
55
+ overrides: override.overrides ?? base.overrides
56
+ };
57
+ }
package/lib/models.js ADDED
@@ -0,0 +1,50 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export async function chooseModel({ agent, tier, config, refreshModels }) {
7
+ if (agent === "claude") {
8
+ return { model: config.claude.aliases[tier] ?? config.claude.aliases.balanced, source: "claude-alias" };
9
+ }
10
+ const catalog = await loadCodexCatalog(config, refreshModels);
11
+ const selected = selectCodexModel(catalog, tier) ?? config.codex.fallbackModels[tier] ?? config.codex.fallbackModels.balanced;
12
+ return { model: selected, source: catalog.length ? "codex-debug-models" : "fallback" };
13
+ }
14
+
15
+ async function loadCodexCatalog(config) {
16
+ const [command, ...args] = config.codex.discoveryCommand.split(/\s+/);
17
+ try {
18
+ const { stdout } = await execFileAsync(command, args, { timeout: 5000, maxBuffer: 1024 * 1024 * 6 });
19
+ const line = stdout.trim().split("\n").find((item) => item.trim().startsWith("{"));
20
+ const parsed = JSON.parse(line);
21
+ return Array.isArray(parsed.models) ? parsed.models.filter((model) => model.visibility !== "hidden") : [];
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+
27
+ function selectCodexModel(models, tier) {
28
+ if (!models.length) return null;
29
+ const visible = models.map((model) => ({
30
+ slug: model.slug,
31
+ text: `${model.slug} ${model.display_name ?? ""} ${model.description ?? ""}`.toLowerCase(),
32
+ priority: Number.isFinite(model.priority) ? model.priority : 1000,
33
+ context: model.context_window ?? 0
34
+ }));
35
+ if (tier === "simple") return best(visible, [/mini|small|fast|efficient/], [/frontier|complex/]);
36
+ if (tier === "balanced") return best(visible, [/gpt-5\.4$|balanced|coding|everyday/], [/mini/]);
37
+ return best(visible, [/gpt-5\.5|frontier|complex|real-world|large/], [/mini|small/]);
38
+ }
39
+
40
+ function best(models, positive, negative) {
41
+ return models
42
+ .map((model) => {
43
+ let score = 1000 - model.priority;
44
+ for (const regex of positive) if (regex.test(model.text)) score += 100;
45
+ for (const regex of negative) if (regex.test(model.text)) score -= 80;
46
+ score += Math.min(50, model.context / 20000);
47
+ return { ...model, score };
48
+ })
49
+ .sort((a, b) => b.score - a.score)[0]?.slug ?? null;
50
+ }
@@ -0,0 +1,38 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parse } from "@babel/parser";
3
+
4
+ export async function analyzeJavaScriptFile(file) {
5
+ try {
6
+ const source = await readFile(file, "utf8");
7
+ const ast = parse(source, {
8
+ sourceType: "unambiguous",
9
+ plugins: ["typescript", "jsx", "decorators-legacy", "classProperties", "dynamicImport"]
10
+ });
11
+ const summary = { ok: true, language: "javascript", functions: 0, classes: 0, branches: 0, imports: 0 };
12
+ walk(ast, (node) => {
13
+ if (!node || typeof node.type !== "string") return;
14
+ if (["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression", "ObjectMethod", "ClassMethod"].includes(node.type)) summary.functions += 1;
15
+ if (["ClassDeclaration", "ClassExpression"].includes(node.type)) summary.classes += 1;
16
+ if (["IfStatement", "ConditionalExpression", "ForStatement", "ForOfStatement", "ForInStatement", "WhileStatement", "DoWhileStatement", "SwitchCase", "TryStatement"].includes(node.type)) summary.branches += 1;
17
+ if (["ImportDeclaration", "ExportNamedDeclaration", "ExportAllDeclaration", "CallExpression"].includes(node.type)) {
18
+ if (node.type !== "CallExpression" || node.callee?.name === "require") summary.imports += 1;
19
+ }
20
+ });
21
+ return summary;
22
+ } catch (error) {
23
+ return { ok: false, file, error: error.message };
24
+ }
25
+ }
26
+
27
+ function walk(value, visit) {
28
+ if (!value || typeof value !== "object") return;
29
+ if (Array.isArray(value)) {
30
+ for (const item of value) walk(item, visit);
31
+ return;
32
+ }
33
+ visit(value);
34
+ for (const [key, child] of Object.entries(value)) {
35
+ if (key === "loc" || key === "start" || key === "end") continue;
36
+ walk(child, visit);
37
+ }
38
+ }
@@ -0,0 +1,15 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import path from "node:path";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export async function analyzePythonFile(file) {
8
+ try {
9
+ const script = path.resolve(new URL("../../../scripts/python_ast_summary.py", import.meta.url).pathname);
10
+ const { stdout } = await execFileAsync("python3", [script, file], { timeout: 5000 });
11
+ return { ok: true, language: "python", ...JSON.parse(stdout) };
12
+ } catch (error) {
13
+ return { ok: false, file, error: error.message };
14
+ }
15
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@ast-ai-model-router/cli",
3
+ "version": "0.1.1",
4
+ "description": "AST-aware Claude and Codex model router for cost-conscious coding tasks.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ast-ai-model-router": "bin/ast-ai-model-router.js",
8
+ "model-router": "bin/ast-ai-model-router.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "lib/",
13
+ "scripts/",
14
+ "skills/",
15
+ ".claude-plugin/",
16
+ ".codex-plugin/",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test",
22
+ "lint": "node --check bin/ast-ai-model-router.js && node --check lib/*.js && node --check lib/parsers/*.js",
23
+ "changeset": "changeset",
24
+ "changeset:version": "changeset version",
25
+ "changeset:publish": "changeset publish",
26
+ "pack:dry-run": "npm publish --dry-run --registry=https://registry.npmjs.org --cache=.npm-cache",
27
+ "publish:local": "npm publish --registry=https://registry.npmjs.org --cache=.npm-cache --provenance --access public"
28
+ },
29
+ "keywords": [
30
+ "llm",
31
+ "token-economics",
32
+ "model-router",
33
+ "claude-code",
34
+ "codex",
35
+ "ast",
36
+ "prompt-cost"
37
+ ],
38
+ "author": {
39
+ "name": "Faraazuddin Mohammed",
40
+ "url": "https://github.com/faraa2m"
41
+ },
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/faraa2m/ast-ai-model-router.git"
46
+ },
47
+ "homepage": "https://github.com/faraa2m/ast-ai-model-router#readme",
48
+ "publishConfig": {
49
+ "registry": "https://registry.npmjs.org",
50
+ "access": "public",
51
+ "provenance": true
52
+ },
53
+ "dependencies": {
54
+ "@babel/parser": "^7.28.5"
55
+ },
56
+ "devDependencies": {
57
+ "@changesets/cli": "^2.31.0"
58
+ },
59
+ "engines": {
60
+ "node": ">=20"
61
+ }
62
+ }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ import ast
3
+ import json
4
+ import sys
5
+
6
+
7
+ class Counter(ast.NodeVisitor):
8
+ def __init__(self):
9
+ self.functions = 0
10
+ self.classes = 0
11
+ self.branches = 0
12
+ self.imports = 0
13
+
14
+ def visit_FunctionDef(self, node):
15
+ self.functions += 1
16
+ self.generic_visit(node)
17
+
18
+ def visit_AsyncFunctionDef(self, node):
19
+ self.functions += 1
20
+ self.generic_visit(node)
21
+
22
+ def visit_ClassDef(self, node):
23
+ self.classes += 1
24
+ self.generic_visit(node)
25
+
26
+ def visit_If(self, node):
27
+ self.branches += 1
28
+ self.generic_visit(node)
29
+
30
+ def visit_For(self, node):
31
+ self.branches += 1
32
+ self.generic_visit(node)
33
+
34
+ def visit_AsyncFor(self, node):
35
+ self.branches += 1
36
+ self.generic_visit(node)
37
+
38
+ def visit_While(self, node):
39
+ self.branches += 1
40
+ self.generic_visit(node)
41
+
42
+ def visit_Try(self, node):
43
+ self.branches += 1
44
+ self.generic_visit(node)
45
+
46
+ def visit_Import(self, node):
47
+ self.imports += len(node.names)
48
+
49
+ def visit_ImportFrom(self, node):
50
+ self.imports += len(node.names)
51
+
52
+
53
+ def main():
54
+ with open(sys.argv[1], "r", encoding="utf-8") as handle:
55
+ tree = ast.parse(handle.read(), filename=sys.argv[1])
56
+ counter = Counter()
57
+ counter.visit(tree)
58
+ print(json.dumps(counter.__dict__))
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: model-router
3
+ description: Use AST AI Model Router to choose an appropriate Claude or Codex model from task and code complexity before launching or recommending an agent command.
4
+ ---
5
+
6
+ # Model Router
7
+
8
+ Use this skill when the user wants cost-aware model selection for a coding task.
9
+
10
+ ## Workflow
11
+
12
+ 1. Run an analysis command from the project root:
13
+
14
+ ```bash
15
+ ast-ai-model-router analyze --agent codex --task "<task>"
16
+ ```
17
+
18
+ or:
19
+
20
+ ```bash
21
+ ast-ai-model-router analyze --agent claude --task "<task>"
22
+ ```
23
+
24
+ 2. If the user wants execution, launch through the wrapper:
25
+
26
+ ```bash
27
+ ast-ai-model-router run codex --task "<task>" -- <codex args>
28
+ ```
29
+
30
+ ```bash
31
+ ast-ai-model-router run claude --task "<task>" -- <claude args>
32
+ ```
33
+
34
+ 3. Explain the selected model in token-economics terms: simple tasks should use faster/cheaper models; complex migrations, security-sensitive work, and architecture planning should use stronger models.
35
+
36
+ ## Notes
37
+
38
+ - Codex model names are discovered dynamically from `codex debug models`.
39
+ - Claude model names use aliases: `haiku`, `sonnet`, `opus`, and `opusplan`.
40
+ - The router analyzes JavaScript/TypeScript with Babel ASTs and Python with stdlib `ast`.