@chlrc/aiw 0.1.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.
package/src/commit.mjs ADDED
@@ -0,0 +1,175 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { cleanAgentText, runAgentForText } from "./agent.mjs";
4
+ import { expandHome, resolveAgent } from "./config.mjs";
5
+ import { capture, tryCapture } from "./run.mjs";
6
+
7
+ const DEFAULT_COMMIT_PROMPT = `Generate a Git commit message for the staged diff.
8
+
9
+ Rules:
10
+ - Output only the commit message. Do not add Markdown fences or explanations.
11
+ - Prefer Conventional Commits when the change clearly fits one type.
12
+ - Keep the first line concise and specific.
13
+ - Use a body only when it clarifies non-obvious context or hook failures.
14
+ - Do not mention generated tooling unless it is part of the change.`;
15
+
16
+ export async function runCommit(config, flags) {
17
+ const repo = capture("git", ["rev-parse", "--show-toplevel"], { cwd: process.cwd() });
18
+ const retries = Number(flags.retries || config.commit.retries || 3);
19
+ const agent = resolveAgent(config, flags.agent || config.commit.agent || config.defaults.agent);
20
+ const customPrompt = loadCustomPrompt(config, flags);
21
+ let lastFailure = null;
22
+
23
+ for (let attempt = 1; attempt <= retries; attempt += 1) {
24
+ const staged = readStagedDiff(config, repo);
25
+ if (!staged.diff) {
26
+ throw withExit("no staged changes; stage files before running aiw commit", 6);
27
+ }
28
+
29
+ const prompt = buildCommitPrompt({
30
+ basePrompt: customPrompt,
31
+ staged,
32
+ attempt,
33
+ retries,
34
+ lastFailure
35
+ });
36
+
37
+ if (flags.printPrompt) {
38
+ console.log(prompt);
39
+ return;
40
+ }
41
+
42
+ const agentResult = runAgentForText(agent, prompt, { cwd: repo });
43
+ if (!agentResult.ok) {
44
+ throw withExit(
45
+ [
46
+ `agent '${agent.name}' failed while generating commit message`,
47
+ agentResult.stderr.trim(),
48
+ agentResult.stdout.trim()
49
+ ].filter(Boolean).join("\n"),
50
+ agentResult.status || 1
51
+ );
52
+ }
53
+
54
+ const message = cleanAgentText(agentResult.stdout);
55
+ if (!message) {
56
+ throw withExit(`agent '${agent.name}' returned an empty commit message`, 7);
57
+ }
58
+
59
+ if (flags.dryRun) {
60
+ console.log(message);
61
+ return;
62
+ }
63
+
64
+ console.log(`[aiw commit] attempt ${attempt}/${retries}`);
65
+ console.log(firstLine(message));
66
+ const commitResult = tryCommit(repo, message);
67
+ if (commitResult.ok) {
68
+ process.stdout.write(commitResult.stdout);
69
+ process.stderr.write(commitResult.stderr);
70
+ return;
71
+ }
72
+
73
+ lastFailure = {
74
+ message,
75
+ output: [commitResult.stdout, commitResult.stderr].filter(Boolean).join("\n").trim()
76
+ };
77
+ process.stderr.write(lastFailure.output ? `${lastFailure.output}\n` : "");
78
+ if (attempt < retries) {
79
+ console.error(`[aiw commit] commit failed; regenerating message and retrying (${attempt + 1}/${retries})`);
80
+ }
81
+ }
82
+
83
+ throw withExit(`git commit failed after ${retries} attempts`, 8);
84
+ }
85
+
86
+ function readStagedDiff(config, repo) {
87
+ const diff = capture("git", ["diff", "--cached", "--no-ext-diff"], { cwd: repo });
88
+ const statResult = tryCapture("git", ["diff", "--cached", "--stat"], { cwd: repo });
89
+ const statusResult = tryCapture("git", ["status", "--short"], { cwd: repo });
90
+ const maxChars = Number(config.commit.max_diff_chars || 120000);
91
+ const truncated = diff.length > maxChars;
92
+ return {
93
+ diff: truncated ? diff.slice(0, maxChars) : diff,
94
+ truncated,
95
+ stat: statResult.ok ? statResult.stdout : "",
96
+ status: statusResult.ok ? statusResult.stdout : ""
97
+ };
98
+ }
99
+
100
+ function buildCommitPrompt({ basePrompt, staged, attempt, retries, lastFailure }) {
101
+ const sections = [
102
+ basePrompt,
103
+ `Attempt: ${attempt}/${retries}`,
104
+ "Git status:",
105
+ fenced(staged.status || "(empty)"),
106
+ "Staged diff stat:",
107
+ fenced(staged.stat || "(empty)")
108
+ ];
109
+
110
+ if (lastFailure) {
111
+ sections.push(
112
+ "The previous commit message failed during git commit hooks. Generate a corrected commit message.",
113
+ "Previous commit message:",
114
+ fenced(lastFailure.message),
115
+ "Hook / git commit output:",
116
+ fenced(lastFailure.output || "(empty)")
117
+ );
118
+ }
119
+
120
+ sections.push(
121
+ staged.truncated
122
+ ? "Staged diff (truncated due to max_diff_chars):"
123
+ : "Staged diff:",
124
+ fenced(staged.diff)
125
+ );
126
+
127
+ return sections.join("\n\n");
128
+ }
129
+
130
+ function loadCustomPrompt(config, flags) {
131
+ const parts = [];
132
+ const defaultPromptFile = config.commit.prompt_file
133
+ ? resolveConfigPath(config, config.commit.prompt_file)
134
+ : "";
135
+ if (defaultPromptFile && fs.existsSync(defaultPromptFile)) {
136
+ parts.push(fs.readFileSync(defaultPromptFile, "utf8").trim());
137
+ }
138
+ if (flags.promptFile) {
139
+ const promptFile = expandHome(flags.promptFile);
140
+ parts.push(fs.readFileSync(promptFile, "utf8").trim());
141
+ }
142
+ if (flags.prompt) {
143
+ parts.push(flags.prompt);
144
+ }
145
+ if (parts.length === 0) {
146
+ parts.push(DEFAULT_COMMIT_PROMPT);
147
+ }
148
+ return parts.filter(Boolean).join("\n\nAdditional user prompt:\n");
149
+ }
150
+
151
+ function resolveConfigPath(config, value) {
152
+ const expanded = expandHome(value);
153
+ return path.isAbsolute(expanded) ? expanded : path.join(config.configDir, expanded);
154
+ }
155
+
156
+ function tryCommit(repo, message) {
157
+ return tryCapture("git", ["commit", "-F", "-"], {
158
+ cwd: repo,
159
+ input: message.endsWith("\n") ? message : `${message}\n`
160
+ });
161
+ }
162
+
163
+ function fenced(value) {
164
+ return `\`\`\`\n${value}\n\`\`\``;
165
+ }
166
+
167
+ function firstLine(value) {
168
+ return value.split(/\r?\n/).find(Boolean) || "(empty commit message)";
169
+ }
170
+
171
+ function withExit(message, exitCode) {
172
+ const error = new Error(message);
173
+ error.exitCode = exitCode;
174
+ return error;
175
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+
8
+ export function projectRoot() {
9
+ return ROOT;
10
+ }
11
+
12
+ export function aiwBinPath() {
13
+ return path.join(ROOT, "bin", "aiw");
14
+ }
15
+
16
+ export function expandHome(value) {
17
+ if (typeof value !== "string") {
18
+ return value;
19
+ }
20
+ if (value === "~") {
21
+ return os.homedir();
22
+ }
23
+ if (value.startsWith("~/")) {
24
+ return path.join(os.homedir(), value.slice(2));
25
+ }
26
+ return value;
27
+ }
28
+
29
+ export function loadConfig() {
30
+ const configDir = process.env.AIW_CONFIG_DIR
31
+ ? expandHome(process.env.AIW_CONFIG_DIR)
32
+ : firstExistingDir([
33
+ path.join(os.homedir(), ".config", "aiw"),
34
+ path.join(ROOT, "config")
35
+ ]);
36
+
37
+ const aiwPath = path.join(configDir, "aiw.toml");
38
+ const agentsPath = path.join(configDir, "agents.toml");
39
+ const aiw = fs.existsSync(aiwPath) ? parseToml(fs.readFileSync(aiwPath, "utf8")) : {};
40
+ const agents = fs.existsSync(agentsPath) ? parseToml(fs.readFileSync(agentsPath, "utf8")) : {};
41
+
42
+ return {
43
+ configDir,
44
+ aiwPath,
45
+ agentsPath,
46
+ defaults: aiw.defaults || {},
47
+ paths: normalizePaths(aiw.paths || {}),
48
+ behavior: aiw.behavior || {},
49
+ commit: aiw.commit || {},
50
+ git: aiw.git || {},
51
+ workspace: aiw.workspace || {},
52
+ agents: agents.agents || {}
53
+ };
54
+ }
55
+
56
+ export function resolveAgent(config, requestedAgent) {
57
+ const name = requestedAgent || config.defaults.agent || "codex";
58
+ const entry = config.agents[name];
59
+ if (!entry || typeof entry.cmd !== "string" || entry.cmd.length === 0) {
60
+ const error = new Error(`unknown agent '${name}' in ${config.agentsPath}`);
61
+ error.exitCode = 2;
62
+ throw error;
63
+ }
64
+ return {
65
+ name,
66
+ cmd: entry.cmd,
67
+ args: Array.isArray(entry.args) ? entry.args.map(String) : [],
68
+ commitArgs: Array.isArray(entry.commit_args) ? entry.commit_args.map(String) : []
69
+ };
70
+ }
71
+
72
+ function normalizePaths(paths) {
73
+ return {
74
+ code_root: expandHome(paths.code_root || path.join(os.homedir(), "Code")),
75
+ worktrees: expandHome(paths.worktrees || "~/worktrees"),
76
+ core_config: expandHome(paths.core_config || ROOT)
77
+ };
78
+ }
79
+
80
+ function firstExistingDir(candidates) {
81
+ for (const candidate of candidates) {
82
+ if (fs.existsSync(candidate)) {
83
+ return candidate;
84
+ }
85
+ }
86
+ return candidates[candidates.length - 1];
87
+ }
88
+
89
+ export function parseToml(source) {
90
+ const root = {};
91
+ let current = root;
92
+ for (const rawLine of source.split(/\r?\n/)) {
93
+ const line = stripComment(rawLine).trim();
94
+ if (!line) {
95
+ continue;
96
+ }
97
+ const sectionMatch = line.match(/^\[([A-Za-z0-9_.-]+)\]$/);
98
+ if (sectionMatch) {
99
+ current = root;
100
+ for (const part of sectionMatch[1].split(".")) {
101
+ current[part] = current[part] || {};
102
+ current = current[part];
103
+ }
104
+ continue;
105
+ }
106
+ const eq = line.indexOf("=");
107
+ if (eq === -1) {
108
+ continue;
109
+ }
110
+ const key = line.slice(0, eq).trim();
111
+ const value = line.slice(eq + 1).trim();
112
+ current[key] = parseTomlValue(value);
113
+ }
114
+ return root;
115
+ }
116
+
117
+ function stripComment(line) {
118
+ let inString = false;
119
+ let escaped = false;
120
+ for (let index = 0; index < line.length; index += 1) {
121
+ const char = line[index];
122
+ if (escaped) {
123
+ escaped = false;
124
+ continue;
125
+ }
126
+ if (char === "\\") {
127
+ escaped = true;
128
+ continue;
129
+ }
130
+ if (char === '"') {
131
+ inString = !inString;
132
+ continue;
133
+ }
134
+ if (char === "#" && !inString) {
135
+ return line.slice(0, index);
136
+ }
137
+ }
138
+ return line;
139
+ }
140
+
141
+ function parseTomlValue(value) {
142
+ if (value === "true") {
143
+ return true;
144
+ }
145
+ if (value === "false") {
146
+ return false;
147
+ }
148
+ if (/^-?\d+(\.\d+)?$/.test(value)) {
149
+ return Number(value);
150
+ }
151
+ if (value.startsWith('"') && value.endsWith('"')) {
152
+ return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
153
+ }
154
+ if (value.startsWith("[") && value.endsWith("]")) {
155
+ const body = value.slice(1, -1).trim();
156
+ if (!body) {
157
+ return [];
158
+ }
159
+ return splitArray(body).map((item) => parseTomlValue(item.trim()));
160
+ }
161
+ return value;
162
+ }
163
+
164
+ function splitArray(body) {
165
+ const items = [];
166
+ let start = 0;
167
+ let inString = false;
168
+ let escaped = false;
169
+ for (let index = 0; index < body.length; index += 1) {
170
+ const char = body[index];
171
+ if (escaped) {
172
+ escaped = false;
173
+ continue;
174
+ }
175
+ if (char === "\\") {
176
+ escaped = true;
177
+ continue;
178
+ }
179
+ if (char === '"') {
180
+ inString = !inString;
181
+ continue;
182
+ }
183
+ if (char === "," && !inString) {
184
+ items.push(body.slice(start, index));
185
+ start = index + 1;
186
+ }
187
+ }
188
+ items.push(body.slice(start));
189
+ return items;
190
+ }
package/src/deps.mjs ADDED
@@ -0,0 +1,172 @@
1
+ import { commandExists, commandPath } from "./run.mjs";
2
+
3
+ const COMMON_TOOLS = [
4
+ "git",
5
+ "cmux",
6
+ "wt",
7
+ "yazi",
8
+ "nvim",
9
+ "lazygit",
10
+ "delta",
11
+ "fd",
12
+ "rg",
13
+ "fzf",
14
+ "bat",
15
+ "eza",
16
+ "codex",
17
+ "claude",
18
+ "opencode",
19
+ "gemini",
20
+ "aider",
21
+ "cmux-git-diff"
22
+ ];
23
+
24
+ export function collectDoctor(config) {
25
+ const tools = COMMON_TOOLS.map((name) => ({
26
+ name,
27
+ ok: commandExists(name),
28
+ path: commandPath(name)
29
+ }));
30
+ const agents = Object.entries(config.agents).map(([name, agent]) => ({
31
+ name,
32
+ cmd: agent.cmd,
33
+ ok: typeof agent.cmd === "string" && commandExists(agent.cmd),
34
+ path: typeof agent.cmd === "string" ? commandPath(agent.cmd) : ""
35
+ }));
36
+ return { tools, agents };
37
+ }
38
+
39
+ export function gate(profile, config, agent) {
40
+ const requirements = requirementsFor(profile, config, agent);
41
+ const missing = [];
42
+ const satisfied = [];
43
+
44
+ for (const requirement of requirements.commands) {
45
+ if (commandExists(requirement)) {
46
+ satisfied.push(requirement);
47
+ } else {
48
+ missing.push(requirement);
49
+ }
50
+ }
51
+
52
+ for (const alternative of requirements.anyOf) {
53
+ const found = alternative.find((candidate) => commandExists(candidate));
54
+ if (found) {
55
+ satisfied.push(found);
56
+ } else {
57
+ missing.push(alternative.join(" or "));
58
+ }
59
+ }
60
+
61
+ return {
62
+ ok: missing.length === 0,
63
+ profile,
64
+ satisfied,
65
+ missing
66
+ };
67
+ }
68
+
69
+ export function assertGate(profile, config, agent) {
70
+ const result = gate(profile, config, agent);
71
+ if (result.ok) {
72
+ return result;
73
+ }
74
+ const error = new Error(
75
+ [
76
+ `dependency gate '${profile}' failed`,
77
+ ...result.missing.map((item) => ` [missing] ${item}`),
78
+ "Install missing tools or change ~/.config/aiw config before retrying."
79
+ ].join("\n")
80
+ );
81
+ error.exitCode = 10;
82
+ throw error;
83
+ }
84
+
85
+ export function printDoctor(config, options = {}) {
86
+ const doctor = collectDoctor(config);
87
+ const gateName = options.gate;
88
+ const gateResult = gateName ? gate(gateName, config, options.agent) : gate("p0", config, options.agent);
89
+ const payload = {
90
+ ok: gateResult.ok,
91
+ gate: gateResult,
92
+ tools: doctor.tools,
93
+ agents: doctor.agents,
94
+ config: {
95
+ configDir: config.configDir,
96
+ aiwPath: config.aiwPath,
97
+ agentsPath: config.agentsPath
98
+ }
99
+ };
100
+
101
+ if (options.json) {
102
+ console.log(JSON.stringify(payload, null, 2));
103
+ } else {
104
+ for (const tool of doctor.tools) {
105
+ const status = tool.ok ? "[ok]" : "[missing]";
106
+ const suffix = tool.path ? ` ${tool.path}` : "";
107
+ console.log(`${status} ${tool.name}${suffix}`);
108
+ }
109
+ console.log("");
110
+ console.log(`gate: ${gateResult.profile}`);
111
+ if (gateResult.ok) {
112
+ console.log("[ok] dependency gate passed");
113
+ } else {
114
+ for (const item of gateResult.missing) {
115
+ console.log(`[missing] ${item}`);
116
+ }
117
+ }
118
+ }
119
+
120
+ if (!gateResult.ok) {
121
+ const error = new Error(`dependency gate '${gateResult.profile}' failed`);
122
+ error.exitCode = 10;
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ function requirementsFor(profile, config, agent) {
128
+ const agentCmd = agent?.cmd;
129
+ switch (profile) {
130
+ case "base":
131
+ return req(["git"]);
132
+ case "init":
133
+ return req(["sh", "git", "cmux", "wt", "yazi", "nvim", "lazygit", "rg", "fzf", "bat", agentCmd].filter(Boolean), [
134
+ ["cmux-git-diff", "delta"]
135
+ ]);
136
+ case "layout":
137
+ return req(["git", "cmux", "yazi", "lazygit", "nvim", agentCmd].filter(Boolean), [
138
+ ["cmux-git-diff", "delta"]
139
+ ]);
140
+ case "cmux-new":
141
+ case "new":
142
+ return req(["git", "wt", "cmux", "yazi", "lazygit", "nvim", agentCmd].filter(Boolean), [
143
+ ["cmux-git-diff", "delta"]
144
+ ]);
145
+ case "worktrunk":
146
+ case "workspace":
147
+ return req(["git", "wt"]);
148
+ case "files":
149
+ return req([config.defaults.files || "yazi"]);
150
+ case "git":
151
+ return req([config.defaults.git || "lazygit", ...(config.git.lazygit_config ? ["delta"] : [])]);
152
+ case "edit":
153
+ return req([config.defaults.editor || "nvim"]);
154
+ case "grep":
155
+ return req(["rg", "fzf", "bat", config.defaults.editor || "nvim"]);
156
+ case "pick":
157
+ return req(["fd", "fzf", "bat", config.defaults.editor || "nvim"]);
158
+ case "tree":
159
+ return req([], [["eza", "find"]]);
160
+ case "diff":
161
+ return req(["git"], [["cmux-git-diff", "delta"]]);
162
+ case "commit":
163
+ return req(["git", agentCmd].filter(Boolean));
164
+ case "p0":
165
+ default:
166
+ return req(["git", "cmux", "wt", "yazi", "nvim", "lazygit", "delta", "fd", "rg", "fzf", "bat", "eza"]);
167
+ }
168
+ }
169
+
170
+ function req(commands, anyOf = []) {
171
+ return { commands, anyOf };
172
+ }