pullsmith 0.0.1 → 0.0.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/bin/pullsmith.js CHANGED
@@ -1,2 +1,23 @@
1
1
  #!/usr/bin/env node
2
- console.log("pullsmith v0.0.1");
2
+
3
+ import { init } from "../src/commands/init.js"
4
+ import { validate } from "../src/commands/validate.js"
5
+ import { doctor } from "../src/commands/doctor.js"
6
+ // import { push } from "../src/commands/push.js"
7
+
8
+ const command = process.argv[2];
9
+
10
+ if (command == "init"){
11
+ init();
12
+ } else if(command == "validate"){
13
+ validate();
14
+ } else if(command == "doctor"){
15
+ doctor();
16
+ } else {
17
+ console.log("Usage: pullsmith <command>");
18
+ console.log("Commands: init, validate, doctor");
19
+ }
20
+
21
+
22
+
23
+ // console.log("pullsmith v0.0.1");
package/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "pullsmith",
3
- "version": "0.0.1",
4
- "description": "Trigger PRs from your Sentry errors",
3
+ "version": "0.0.2",
4
+ "description": "Run AI agents in your CI/CD pipeline with a single YAML file.",
5
+ "homepage": "https://pullsmith.dev",
6
+ "bugs": {
7
+ "url": "https://github.com/pullsmith/cli/issues"
8
+ },
5
9
  "bin": {
6
10
  "pullsmith": "./bin/pullsmith.js"
7
11
  },
12
+ "type": "module",
8
13
  "repository": {
9
14
  "type": "git",
10
15
  "url": "https://github.com/pullsmith/cli"
@@ -0,0 +1,50 @@
1
+ import { getToken } from "../lib/auth.js";
2
+ import { getRepoRemoteUrl } from "../lib/git.js";
3
+ import { PULLSMITH_BASE_URL } from "../lib/config.js";
4
+
5
+ export async function doctor() {
6
+ let token;
7
+ let repo;
8
+
9
+ try {
10
+ token = getToken();
11
+ repo = getRepoRemoteUrl();
12
+ } catch (err) {
13
+ console.error(err.message);
14
+ process.exit(1);
15
+ }
16
+
17
+ try {
18
+ const response = await fetch(`${PULLSMITH_BASE_URL}/api/doctor`, {
19
+ method: "POST",
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ "Authorization": `Bearer ${token}`
23
+ },
24
+ body: JSON.stringify({ repo })
25
+ });
26
+
27
+ if (!response.ok) {
28
+ const text = await response.text();
29
+ const error = getResponseError(text);
30
+ console.error(error ?? "Pullsmith doctor failed.");
31
+ process.exit(1);
32
+ }
33
+
34
+ console.log("Pullsmith doctor passed.");
35
+ } catch (err) {
36
+ console.error(err.message ?? "Pullsmith doctor failed.");
37
+ process.exit(1);
38
+ }
39
+ }
40
+
41
+ function getResponseError(text) {
42
+ if (!text) return null;
43
+
44
+ try {
45
+ const data = JSON.parse(text);
46
+ return data?.error ?? text;
47
+ } catch {
48
+ return text;
49
+ }
50
+ }
@@ -0,0 +1,202 @@
1
+ import http from "http";
2
+ import { exec } from "child_process";
3
+ import { getRepoRemoteUrl } from "../lib/git.js";
4
+ import { saveToken as saveTokenLocally } from "../lib/auth.js";
5
+ import { PULLSMITH_BASE_URL } from "../lib/config.js";
6
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from "fs";
7
+ import { join } from "path";
8
+
9
+ const PORT = 9421;
10
+
11
+ export async function init() {
12
+ let repoUrl;
13
+
14
+ try {
15
+ repoUrl = getRepoRemoteUrl();
16
+ } catch (err) {
17
+ console.error(err.message);
18
+ process.exit(1);
19
+ }
20
+
21
+ const connectUrl = `${PULLSMITH_BASE_URL}/connect?repo=${encodeURIComponent(repoUrl)}`;
22
+
23
+ console.log("Opening browser to authenticate...");
24
+
25
+ if (process.platform === "darwin") {
26
+ // macOS
27
+ exec(`open "${connectUrl}"`);
28
+ } else if (process.platform === "win32") {
29
+ exec(`start "" "${connectUrl}"`);
30
+ } else {
31
+ exec(`xdg-open "${connectUrl}"`);
32
+ }
33
+
34
+
35
+ try {
36
+ await waitForNextJsInternalToken(repoUrl);
37
+ } catch (err) {
38
+ console.error(err.message);
39
+ process.exit(1);
40
+ }
41
+ }
42
+
43
+ function createPullsmithFile(){
44
+ try {
45
+ const filePath = join(process.cwd(), ".pullsmith");
46
+
47
+ if (existsSync(filePath)) {
48
+ console.log(".pullsmith file already exits, skipping.");
49
+ return;
50
+ }
51
+
52
+ const promptPath = new URL("../../src/utils/templates/prompt.yaml", import.meta.url);
53
+ const prompt = readFileSync(promptPath, "utf8");
54
+
55
+ writeFileSync(filePath, prompt, "utf8");
56
+
57
+ console.log(".pullsmith file created!");
58
+ } catch (err) {
59
+ throw new Error("☹️ Failed to create local .pullsmith file.");
60
+ }
61
+ }
62
+
63
+
64
+ function getGithubWorkflowContent(secretName) {
65
+ const templatePath = new URL("../../src/utils/templates/workflow.yaml", import.meta.url);
66
+ return readFileSync(templatePath, "utf8").replaceAll("__SECRET_NAME__", secretName);
67
+ }
68
+
69
+
70
+ function createGithubWorkflowFile(secretName) {
71
+ try {
72
+ const dirPath = join(process.cwd(), ".github", "workflows");
73
+ const filePath = join(dirPath, "pullsmith.yaml");
74
+
75
+ if (existsSync(filePath)) {
76
+ console.log("Workflow file already exists, skipping.");
77
+ return;
78
+ }
79
+
80
+ mkdirSync(dirPath, { recursive: true });
81
+
82
+ writeFileSync(filePath, getGithubWorkflowContent(secretName), "utf8");
83
+
84
+ console.log("✅ Workflow file created at .github/workflows/pullsmith.yml");
85
+ } catch (err) {
86
+ throw new Error(`Failed to create Github workflow file: ${err.message}`);
87
+ }
88
+ }
89
+
90
+ function deleteWorkflowFile() {
91
+ try {
92
+ // .github/workflows/pullsmith.yml
93
+ const filePath = join(process.cwd(), ".github", "workflows", "pullsmith.yml");
94
+ if (existsSync(filePath)) {
95
+ rmSync(filePath);
96
+ console.log("🗑️ Removed old workflow file.");
97
+ }
98
+ } catch (err) {
99
+ throw new Error("☹️ Failed to delete local .pullsmith file.");
100
+ }
101
+ }
102
+
103
+ // We only get this token if Github auth is successful
104
+ function waitForNextJsInternalToken(repoUrl) {
105
+ return new Promise((resolve, reject) => {
106
+ const server = http.createServer(async (req, res) => {
107
+ const url = new URL(req.url, `http://localhost:${PORT}`);
108
+ const internalNextJSToken = url.searchParams.get("token");
109
+
110
+ if (internalNextJSToken) {
111
+ saveTokenLocally(internalNextJSToken);
112
+ deleteWorkflowFile();
113
+ createPullsmithFile();
114
+
115
+ try {
116
+ const secretName = await checkThatClaudeAPIKeyIsInSecrets(internalNextJSToken, repoUrl);
117
+ createGithubWorkflowFile(secretName);
118
+
119
+ res.writeHead(200);
120
+ res.end("Authentication successful! You can close this tab.");
121
+ server.close();
122
+ console.log("Authenticated successfully!");
123
+ resolve();
124
+ } catch (err) {
125
+ reject(err);
126
+ }
127
+ }
128
+ });
129
+
130
+ server.listen(PORT, () => {
131
+ console.log(`Waiting for authentication on port ${PORT}...`);
132
+ });
133
+
134
+ server.on("error", reject);
135
+ });
136
+ }
137
+
138
+
139
+ const missingClaudeSecretsMsg = (secretUrl) => `❌ No Anthropic API key found in Github Actions secrets.\n
140
+ Please add your Claude API key at:
141
+ ${secretUrl}
142
+ Make sure to name it: ANTHROPIC_API_KEY or CLAUDE_API_KEY
143
+ `;
144
+
145
+
146
+ async function checkThatClaudeAPIKeyIsInSecrets(nextJsInternalToken, repoUrl) {
147
+ try {
148
+ const response = await fetch(`${PULLSMITH_BASE_URL}/api/init`, {
149
+ method: "POST",
150
+ headers: {
151
+ "Content-Type": "application/json",
152
+ "Authorization": `Bearer ${nextJsInternalToken}`
153
+ },
154
+ body: JSON.stringify({ repo: repoUrl })
155
+ });
156
+
157
+ const data = await response.json();
158
+
159
+ if (!response.ok) {
160
+ // if the github token access has expired this fires
161
+ throw new Error(data.error ?? "Problem with Github Auth") ;
162
+ }
163
+
164
+ if (data.secretName) {
165
+ console.log(`✅ You have a Claude API key in Github Actions: ${data.secretName}`);
166
+ return data.secretName;
167
+ } else {
168
+ throw new Error(missingClaudeSecretsMsg(data.secretsUrl))
169
+ }
170
+
171
+ } catch (err) {
172
+ throw new Error(err.message ?? 'Failed to find Claude API key in Github Actions.');
173
+ }
174
+ }
175
+
176
+ // async function checkAnthropicSecret(repoUrl, token) {
177
+ // const { owner, repo } = parseRepoInfo(repoUrl);
178
+ //
179
+ // const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/actions/secrets`, {
180
+ // headers: {
181
+ // "Authorization": `Bearer ${token}`,
182
+ // "Accept": "application/vnd.github+json"
183
+ // }
184
+ // });
185
+ //
186
+ // const data = await response.json();
187
+ // const secrets = data.secrets || [];
188
+ //
189
+ // const hasKey = secrets.some(s =>
190
+ // s.name.includes("ANTHROPIC") || s.name.includes("CLAUDE")
191
+ // );
192
+ //
193
+ // if (!hasKey) {
194
+ // const secretsUrl = `https://github.com/${owner}/${repo}/settings/secrets/actions/new`;
195
+ // console.log(`\n⚠️ No Anthropic API key found in your repo secrets.`);
196
+ // console.log(` Please add your API key at:`);
197
+ // console.log(` ${secretsUrl}`);
198
+ // console.log(` Name it: ANTHROPIC_API_KEY or CLAUDE_API_KEY\n`);
199
+ // } else {
200
+ // console.log("✅ Anthropic API key found in repo secrets.");
201
+ // }
202
+ // }
@@ -0,0 +1,47 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { PULLSMITH_BASE_URL } from "../lib/config.js";
4
+
5
+ export async function validate() {
6
+ const filePath = join(process.cwd(), ".pullsmith");
7
+
8
+ if (!existsSync(filePath)) {
9
+ console.error("Missing .pullsmith file. Run `pullsmith init` first.");
10
+ process.exit(1);
11
+ }
12
+
13
+ const file = readFileSync(filePath, "utf8");
14
+
15
+ try {
16
+ const response = await fetch(`${PULLSMITH_BASE_URL}/api/validate`, {
17
+ method: "POST",
18
+ headers: {
19
+ "Content-Type": "application/json"
20
+ },
21
+ body: JSON.stringify({ file })
22
+ });
23
+
24
+ if (!response.ok) {
25
+ const text = await response.text();
26
+ const error = getResponseError(text);
27
+ console.error(error ?? "Failed to validate .pullsmith file.");
28
+ process.exit(1);
29
+ }
30
+
31
+ console.log(".pullsmith is valid.");
32
+ } catch (err) {
33
+ console.error(err.message ?? "Failed to validate .pullsmith file.");
34
+ process.exit(1);
35
+ }
36
+ }
37
+
38
+ function getResponseError(text) {
39
+ if (!text) return null;
40
+
41
+ try {
42
+ const data = JSON.parse(text);
43
+ return data?.error ?? text;
44
+ } catch {
45
+ return text;
46
+ }
47
+ }
@@ -0,0 +1,25 @@
1
+ import { mkdirSync, writeFileSync, readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ const credentialsPath = join(homedir(), ".pullsmith", "credentials");
6
+
7
+ // Saves generated token from Pullsmith.dev in local machine ./pullsmith/credentials
8
+ export function saveToken(token) {
9
+ try {
10
+ mkdirSync(join(homedir(), ".pullsmith"), { recursive: true });
11
+ writeFileSync(credentialsPath, JSON.stringify({ token }), "utf8");
12
+ } catch (err) {
13
+ throw new Error("☹️ Failed to save local credentials in .pullsmith file.");
14
+ }
15
+ }
16
+
17
+ // Reads the token so the CLI can make authenticated requests to Pullsmith's API
18
+ export function getToken() {
19
+ try {
20
+ const file = readFileSync(credentialsPath, "utf8");
21
+ return JSON.parse(file).token;
22
+ } catch {
23
+ throw new Error("Not authenticated. Run `pullsmith init` first.");
24
+ }
25
+ }
@@ -0,0 +1 @@
1
+ export const PULLSMITH_BASE_URL = process.env.PULLSMITH_BASE_URL ?? "https://pullsmith.dev";
package/src/lib/git.js ADDED
@@ -0,0 +1,16 @@
1
+ import { execSync } from "child_process";
2
+
3
+ export function getRepoRemoteUrl(){
4
+ try {
5
+ return execSync("git remote get-url origin", { stdio: "pipe" }).toString().trim();
6
+ } catch (err) {
7
+ throw new Error("☹️ Looks like you're not inside a Git repo. cd into your project, then try again.");
8
+ }
9
+ }
10
+
11
+ export function parseRepoInfo(remoteUrl) {
12
+ const match = remoteUrl.match(/github\.com[:/](.+?)\/(.+?)(\.git)?$/);
13
+ if (!match) throw new Error("Could not parse GitHub repo info from remote URL.");
14
+ return { owner: match[1], repo: match[2] };
15
+ }
16
+
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+
3
+ AGENT=$(awk -F': *' '/^sentry_agent:/{print $2; exit}' .pullsmith)
4
+
5
+ MODEL=$(awk -v a="$AGENT" '
6
+ /^[[:space:]]*-?[[:space:]]*name:[[:space:]]*/ {
7
+ name=$0; sub(/^[[:space:]]*-?[[:space:]]*name:[[:space:]]*/,"",name)
8
+ here=(name==a); next
9
+ }
10
+ here && /^[[:space:]]*model:[[:space:]]*/ {
11
+ m=$0; sub(/^[[:space:]]*model:[[:space:]]*/,"",m)
12
+ sub(/[[:space:]]*(#.*)?$/,"",m) # strip trailing spaces/comments
13
+ print m; exit
14
+ }
15
+ ' .pullsmith)
16
+
17
+ printf '%s\n' "$MODEL"
@@ -0,0 +1,20 @@
1
+ #!/bin/sh
2
+
3
+ AGENT=$(awk -F': *' '/^sentry_agent:/{print $2; exit}' .pullsmith)
4
+
5
+ PROMPT=$(awk -v a="$AGENT" '
6
+ /^[[:space:]]*-?[[:space:]]*name:[[:space:]]*/ {
7
+ name=$0; sub(/^[[:space:]]*-?[[:space:]]*name:[[:space:]]*/,"",name)
8
+ here=(name==a); inp=0; next
9
+ }
10
+ here && /prompt:[[:space:]]*\|/ { inp=1; ind=-1; next }
11
+ inp {
12
+ if ($0 ~ /^[[:space:]]*$/) { print ""; next }
13
+ n=match($0,/[^[:space:]]/)-1
14
+ if (ind<0) ind=n
15
+ if (n<ind) { inp=0; next }
16
+ print substr($0, ind+1)
17
+ }
18
+ ' .pullsmith)
19
+
20
+ printf '%s\n' "$PROMPT"
@@ -0,0 +1,8 @@
1
+ sentry_agent: sentry_error_fixer
2
+
3
+ agents:
4
+ - name: sentry_error_fixer
5
+ prompt: |
6
+ On a scale from 1 to 10, how beautiful is my code? Respond with only numbers 1 to 10.
7
+ model: claude-haiku-4-5
8
+ provider: claude
@@ -0,0 +1,69 @@
1
+ name: Pullsmith
2
+ on:
3
+ workflow_dispatch:
4
+ inputs:
5
+ error:
6
+ description: "Sentry error title"
7
+ required: false
8
+ default: "Sentry error"
9
+
10
+ jobs:
11
+ run:
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: write
15
+ pull-requests: write
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - name: Run Claude Code
20
+ env:
21
+ ANTHROPIC_API_KEY: ${{ secrets.__SECRET_NAME__ }}
22
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23
+ run: |
24
+ ID=$(gh api "/users/pullsmith-dev[bot]" --jq .id)
25
+ git config user.email "${ID}+pullsmith-dev[bot]@users.noreply.github.com"
26
+ git config user.name "pullsmith-dev[bot]"
27
+ ERROR="${{ github.event.inputs.error }}"
28
+ SLUG=$(echo "$ERROR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//' | cut -c1-50)
29
+ BRANCH="pullsmith/fix-$SLUG"
30
+ AGENT=$(awk -F': *' '/^sentry_agent:/{print $2; exit}' .pullsmith)
31
+ PROMPT=$(awk -v a="$AGENT" '
32
+ /^[[:space:]]*-?[[:space:]]*name:[[:space:]]*/ {
33
+ name=$0; sub(/^[[:space:]]*-?[[:space:]]*name:[[:space:]]*/,"",name)
34
+ here=(name==a); inp=0; next
35
+ }
36
+ here && /prompt:[[:space:]]*|/ { inp=1; ind=-1; next }
37
+ inp {
38
+ if ($0 ~ /^[[:space:]]*$/) { print ""; next }
39
+ n=match($0,/[^[:space:]]/)-1
40
+ if (ind<0) ind=n
41
+ if (n<ind) { inp=0; next }
42
+ print substr($0, ind+1)
43
+ }
44
+ ' .pullsmith)
45
+ MODEL=$(awk -v a="$AGENT" '
46
+ /^[[:space:]]*-?[[:space:]]*name:[[:space:]]*/ {
47
+ name=$0; sub(/^[[:space:]]*-?[[:space:]]*name:[[:space:]]*/,"",name)
48
+ here=(name==a); next
49
+ }
50
+ here && /^[[:space:]]*model:[[:space:]]*/ {
51
+ m=$0; sub(/^[[:space:]]*model:[[:space:]]*/,"",m)
52
+ sub(/[[:space:]]*(#.*)?$/,"",m) # strip trailing spaces/comments
53
+ print m; exit
54
+ }
55
+ ' .pullsmith)
56
+
57
+
58
+ git checkout -b "$BRANCH"
59
+ npx @anthropic-ai/claude-code -p --model "$MODEL" --dangerously-skip-permissions "$PROMPT. You are fixing this Sentry error: $ERROR. ONLY edit the code in the working tree to fix it. Do NOT run git, do NOT commit, do NOT push, and do NOT open a pull request - the surrounding workflow handles all of that."
60
+
61
+ git add -A
62
+ if git diff --cached --quiet; then
63
+ echo "No changes produced; nothing to do."
64
+ exit 0;
65
+ fi
66
+
67
+ git commit -m "fix: $ERROR"
68
+ git push origin "$BRANCH"
69
+ gh pr create --title "$ERROR" --body "Automated fix by Pullsmith for Sentry error: $ERROR" --base "${{ github.ref_name }}" --head "$BRANCH"