thepopebot 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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/api/index.js +357 -0
- package/bin/cli.js +278 -0
- package/config/index.js +29 -0
- package/config/instrumentation.js +29 -0
- package/docker/Dockerfile +51 -0
- package/docker/entrypoint.sh +100 -0
- package/lib/actions.js +40 -0
- package/lib/claude/conversation.js +76 -0
- package/lib/claude/index.js +142 -0
- package/lib/claude/tools.js +54 -0
- package/lib/cron.js +60 -0
- package/lib/paths.js +30 -0
- package/lib/tools/create-job.js +40 -0
- package/lib/tools/github.js +122 -0
- package/lib/tools/openai.js +35 -0
- package/lib/tools/telegram.js +222 -0
- package/lib/triggers.js +105 -0
- package/lib/utils/render-md.js +39 -0
- package/package.json +57 -0
- package/pi/extensions/env-sanitizer/index.ts +48 -0
- package/pi/extensions/env-sanitizer/package.json +5 -0
- package/pi/skills/llm-secrets/SKILL.md +34 -0
- package/pi/skills/llm-secrets/llm-secrets.js +34 -0
- package/setup/lib/auth.mjs +160 -0
- package/setup/lib/github.mjs +148 -0
- package/setup/lib/prerequisites.mjs +135 -0
- package/setup/lib/prompts.mjs +268 -0
- package/setup/lib/telegram-verify.mjs +66 -0
- package/setup/lib/telegram.mjs +76 -0
- package/setup/package.json +6 -0
- package/setup/setup-telegram.mjs +236 -0
- package/setup/setup.mjs +540 -0
- package/templates/.env.example +38 -0
- package/templates/.github/workflows/auto-merge.yml +117 -0
- package/templates/.github/workflows/docker-build.yml +34 -0
- package/templates/.github/workflows/run-job.yml +40 -0
- package/templates/.github/workflows/update-event-handler.yml +126 -0
- package/templates/.pi/skills/modify-self/SKILL.md +12 -0
- package/templates/CLAUDE.md +52 -0
- package/templates/app/api/[...thepopebot]/route.js +1 -0
- package/templates/app/layout.js +12 -0
- package/templates/app/page.js +8 -0
- package/templates/instrumentation.js +1 -0
- package/templates/next.config.mjs +3 -0
- package/templates/operating_system/AGENT.md +32 -0
- package/templates/operating_system/CHATBOT.md +74 -0
- package/templates/operating_system/CRONS.json +16 -0
- package/templates/operating_system/HEARTBEAT.md +3 -0
- package/templates/operating_system/JOB_SUMMARY.md +36 -0
- package/templates/operating_system/SOUL.md +17 -0
- package/templates/operating_system/TELEGRAM.md +21 -0
- package/templates/operating_system/TRIGGERS.json +18 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
const { Bot } = require('grammy');
|
|
2
|
+
const { hydrateReply } = require('@grammyjs/parse-mode');
|
|
3
|
+
|
|
4
|
+
const MAX_LENGTH = 4096;
|
|
5
|
+
|
|
6
|
+
let bot = null;
|
|
7
|
+
let currentToken = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get or create bot instance
|
|
11
|
+
* @param {string} token - Bot token from @BotFather
|
|
12
|
+
* @returns {Bot} grammY Bot instance
|
|
13
|
+
*/
|
|
14
|
+
function getBot(token) {
|
|
15
|
+
if (!bot || currentToken !== token) {
|
|
16
|
+
bot = new Bot(token);
|
|
17
|
+
bot.use(hydrateReply);
|
|
18
|
+
currentToken = token;
|
|
19
|
+
}
|
|
20
|
+
return bot;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Set webhook for a Telegram bot
|
|
25
|
+
* @param {string} botToken - Bot token from @BotFather
|
|
26
|
+
* @param {string} webhookUrl - HTTPS URL to receive updates
|
|
27
|
+
* @param {string} [secretToken] - Optional secret token for verification
|
|
28
|
+
* @returns {Promise<boolean>} - Success status
|
|
29
|
+
*/
|
|
30
|
+
async function setWebhook(botToken, webhookUrl, secretToken) {
|
|
31
|
+
const b = getBot(botToken);
|
|
32
|
+
const options = {};
|
|
33
|
+
if (secretToken) {
|
|
34
|
+
options.secret_token = secretToken;
|
|
35
|
+
}
|
|
36
|
+
return b.api.setWebhook(webhookUrl, options);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Smart split text into chunks that fit Telegram's limit
|
|
41
|
+
* Prefers splitting at paragraph > newline > sentence > space
|
|
42
|
+
* @param {string} text - Text to split
|
|
43
|
+
* @param {number} maxLength - Maximum chunk length
|
|
44
|
+
* @returns {string[]} Array of chunks
|
|
45
|
+
*/
|
|
46
|
+
function smartSplit(text, maxLength = MAX_LENGTH) {
|
|
47
|
+
if (text.length <= maxLength) return [text];
|
|
48
|
+
|
|
49
|
+
const chunks = [];
|
|
50
|
+
let remaining = text;
|
|
51
|
+
|
|
52
|
+
while (remaining.length > 0) {
|
|
53
|
+
if (remaining.length <= maxLength) {
|
|
54
|
+
chunks.push(remaining);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const chunk = remaining.slice(0, maxLength);
|
|
59
|
+
let splitAt = -1;
|
|
60
|
+
|
|
61
|
+
// Try to split at natural boundaries (prefer earlier ones)
|
|
62
|
+
for (const delim of ['\n\n', '\n', '. ', ' ']) {
|
|
63
|
+
const idx = chunk.lastIndexOf(delim);
|
|
64
|
+
if (idx > maxLength * 0.3) {
|
|
65
|
+
splitAt = idx + delim.length;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (splitAt === -1) splitAt = maxLength;
|
|
71
|
+
|
|
72
|
+
chunks.push(remaining.slice(0, splitAt).trimEnd());
|
|
73
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return chunks;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Escape HTML special characters
|
|
81
|
+
* @param {string} text - Text to escape
|
|
82
|
+
* @returns {string} Escaped text
|
|
83
|
+
*/
|
|
84
|
+
function escapeHtml(text) {
|
|
85
|
+
if (!text) return '';
|
|
86
|
+
return text
|
|
87
|
+
.replace(/&/g, '&')
|
|
88
|
+
.replace(/</g, '<')
|
|
89
|
+
.replace(/>/g, '>');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Send a message to a Telegram chat with HTML formatting
|
|
94
|
+
* Automatically splits long messages
|
|
95
|
+
* @param {string} botToken - Bot token from @BotFather
|
|
96
|
+
* @param {number|string} chatId - Chat ID to send message to
|
|
97
|
+
* @param {string} text - Message text (HTML formatted)
|
|
98
|
+
* @param {Object} [options] - Additional options
|
|
99
|
+
* @param {boolean} [options.disablePreview] - Disable link previews
|
|
100
|
+
* @returns {Promise<Object>} - Last message sent
|
|
101
|
+
*/
|
|
102
|
+
async function sendMessage(botToken, chatId, text, options = {}) {
|
|
103
|
+
const b = getBot(botToken);
|
|
104
|
+
// Strip HTML comments — Telegram's HTML parser doesn't support them
|
|
105
|
+
text = text.replace(/<!--[\s\S]*?-->/g, '');
|
|
106
|
+
const chunks = smartSplit(text, MAX_LENGTH);
|
|
107
|
+
|
|
108
|
+
let lastMessage;
|
|
109
|
+
for (const chunk of chunks) {
|
|
110
|
+
lastMessage = await b.api.sendMessage(chatId, chunk, {
|
|
111
|
+
parse_mode: 'HTML',
|
|
112
|
+
link_preview_options: { is_disabled: options.disablePreview ?? false },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return lastMessage;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Format a job notification message
|
|
121
|
+
* @param {Object} params - Notification parameters
|
|
122
|
+
* @param {string} params.jobId - Full job ID
|
|
123
|
+
* @param {boolean} params.success - Whether job succeeded
|
|
124
|
+
* @param {string} params.summary - Job summary text
|
|
125
|
+
* @param {string} params.prUrl - PR URL
|
|
126
|
+
* @returns {string} Formatted HTML message
|
|
127
|
+
*/
|
|
128
|
+
function formatJobNotification({ jobId, success, summary, prUrl }) {
|
|
129
|
+
const emoji = success ? '\u2705' : '\u26a0\ufe0f';
|
|
130
|
+
const status = success ? 'complete' : 'had issues';
|
|
131
|
+
const shortId = jobId.slice(0, 8);
|
|
132
|
+
|
|
133
|
+
return `${emoji} <b>Job ${shortId}</b> ${status}
|
|
134
|
+
|
|
135
|
+
${escapeHtml(summary)}
|
|
136
|
+
|
|
137
|
+
<a href="${prUrl}">View PR</a>`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Download a file from Telegram servers
|
|
142
|
+
* @param {string} botToken - Bot token from @BotFather
|
|
143
|
+
* @param {string} fileId - Telegram file_id
|
|
144
|
+
* @returns {Promise<{buffer: Buffer, filename: string}>}
|
|
145
|
+
*/
|
|
146
|
+
async function downloadFile(botToken, fileId) {
|
|
147
|
+
// Get file path from Telegram
|
|
148
|
+
const fileInfoRes = await fetch(
|
|
149
|
+
`https://api.telegram.org/bot${botToken}/getFile?file_id=${fileId}`
|
|
150
|
+
);
|
|
151
|
+
const fileInfo = await fileInfoRes.json();
|
|
152
|
+
if (!fileInfo.ok) {
|
|
153
|
+
throw new Error(`Telegram API error: ${fileInfo.description}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const filePath = fileInfo.result.file_path;
|
|
157
|
+
|
|
158
|
+
// Download file
|
|
159
|
+
const fileRes = await fetch(
|
|
160
|
+
`https://api.telegram.org/file/bot${botToken}/${filePath}`
|
|
161
|
+
);
|
|
162
|
+
const buffer = Buffer.from(await fileRes.arrayBuffer());
|
|
163
|
+
const filename = filePath.split('/').pop();
|
|
164
|
+
|
|
165
|
+
return { buffer, filename };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* React to a message with an emoji
|
|
170
|
+
* @param {string} botToken - Bot token from @BotFather
|
|
171
|
+
* @param {number|string} chatId - Chat ID
|
|
172
|
+
* @param {number} messageId - Message ID to react to
|
|
173
|
+
* @param {string} [emoji='\ud83d\udc4d'] - Emoji to react with
|
|
174
|
+
*/
|
|
175
|
+
async function reactToMessage(botToken, chatId, messageId, emoji = '\ud83d\udc4d') {
|
|
176
|
+
const b = getBot(botToken);
|
|
177
|
+
await b.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Start a repeating typing indicator for a chat.
|
|
182
|
+
* Returns a stop function. The indicator naturally expires after 5s,
|
|
183
|
+
* so we re-send with random gaps (5.5-8s) to look human.
|
|
184
|
+
* @param {string} botToken - Bot token from @BotFather
|
|
185
|
+
* @param {number|string} chatId - Chat ID
|
|
186
|
+
* @returns {Function} Call to stop the typing indicator
|
|
187
|
+
*/
|
|
188
|
+
function startTypingIndicator(botToken, chatId) {
|
|
189
|
+
const b = getBot(botToken);
|
|
190
|
+
let timeout;
|
|
191
|
+
let stopped = false;
|
|
192
|
+
|
|
193
|
+
function scheduleNext() {
|
|
194
|
+
if (stopped) return;
|
|
195
|
+
const delay = 5500 + Math.random() * 2500;
|
|
196
|
+
timeout = setTimeout(() => {
|
|
197
|
+
if (stopped) return;
|
|
198
|
+
b.api.sendChatAction(chatId, 'typing').catch(() => {});
|
|
199
|
+
scheduleNext();
|
|
200
|
+
}, delay);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
b.api.sendChatAction(chatId, 'typing').catch(() => {});
|
|
204
|
+
scheduleNext();
|
|
205
|
+
|
|
206
|
+
return () => {
|
|
207
|
+
stopped = true;
|
|
208
|
+
clearTimeout(timeout);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = {
|
|
213
|
+
getBot,
|
|
214
|
+
setWebhook,
|
|
215
|
+
sendMessage,
|
|
216
|
+
smartSplit,
|
|
217
|
+
escapeHtml,
|
|
218
|
+
formatJobNotification,
|
|
219
|
+
downloadFile,
|
|
220
|
+
reactToMessage,
|
|
221
|
+
startTypingIndicator,
|
|
222
|
+
};
|
package/lib/triggers.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const paths = require('./paths');
|
|
3
|
+
|
|
4
|
+
const { executeAction } = require('./actions');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Replace {{body.field}} templates with values from request context
|
|
8
|
+
* @param {string} template - String with {{body.field}} placeholders
|
|
9
|
+
* @param {Object} context - { body, query, headers }
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
function resolveTemplate(template, context) {
|
|
13
|
+
return template.replace(/\{\{(\w+)(?:\.(\w+))?\}\}/g, (match, source, field) => {
|
|
14
|
+
const data = context[source];
|
|
15
|
+
if (data === undefined) return match;
|
|
16
|
+
if (!field) return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
17
|
+
if (data[field] !== undefined) return String(data[field]);
|
|
18
|
+
return match;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Execute all actions for a trigger (fire-and-forget)
|
|
24
|
+
* @param {Object} trigger - Trigger config object
|
|
25
|
+
* @param {Object} context - { body, query, headers }
|
|
26
|
+
*/
|
|
27
|
+
async function executeActions(trigger, context) {
|
|
28
|
+
for (const action of trigger.actions) {
|
|
29
|
+
try {
|
|
30
|
+
const resolved = { ...action };
|
|
31
|
+
if (resolved.command) resolved.command = resolveTemplate(resolved.command, context);
|
|
32
|
+
if (resolved.job) resolved.job = resolveTemplate(resolved.job, context);
|
|
33
|
+
const result = await executeAction(resolved, { cwd: paths.triggersDir, data: context.body });
|
|
34
|
+
console.log(`[TRIGGER] ${trigger.name}: ${result || 'ran'}`);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`[TRIGGER] ${trigger.name}: error - ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load triggers from TRIGGERS.json and return trigger map + fire function
|
|
43
|
+
* @returns {{ triggerMap: Map, fireTriggers: Function }}
|
|
44
|
+
*/
|
|
45
|
+
function loadTriggers() {
|
|
46
|
+
const triggerFile = paths.triggersFile;
|
|
47
|
+
const triggerMap = new Map();
|
|
48
|
+
|
|
49
|
+
console.log('\n--- Triggers ---');
|
|
50
|
+
|
|
51
|
+
if (!fs.existsSync(triggerFile)) {
|
|
52
|
+
console.log('No TRIGGERS.json found');
|
|
53
|
+
console.log('----------------\n');
|
|
54
|
+
return { triggerMap, fireTriggers: () => {} };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const triggers = JSON.parse(fs.readFileSync(triggerFile, 'utf8'));
|
|
58
|
+
|
|
59
|
+
for (const trigger of triggers) {
|
|
60
|
+
if (trigger.enabled === false) continue;
|
|
61
|
+
|
|
62
|
+
if (!triggerMap.has(trigger.watch_path)) {
|
|
63
|
+
triggerMap.set(trigger.watch_path, []);
|
|
64
|
+
}
|
|
65
|
+
triggerMap.get(trigger.watch_path).push(trigger);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const activeCount = [...triggerMap.values()].reduce((sum, arr) => sum + arr.length, 0);
|
|
69
|
+
|
|
70
|
+
if (activeCount === 0) {
|
|
71
|
+
console.log('No active triggers');
|
|
72
|
+
} else {
|
|
73
|
+
for (const [watchPath, pathTriggers] of triggerMap) {
|
|
74
|
+
for (const t of pathTriggers) {
|
|
75
|
+
const actionTypes = t.actions.map(a => a.type || 'agent').join(', ');
|
|
76
|
+
console.log(` ${t.name}: ${watchPath} (${actionTypes})`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('----------------\n');
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fire matching triggers for a given path (non-blocking)
|
|
85
|
+
* @param {string} path - Request path (e.g., '/webhook')
|
|
86
|
+
* @param {Object} body - Request body
|
|
87
|
+
* @param {Object} [query={}] - Query parameters
|
|
88
|
+
* @param {Object} [headers={}] - Request headers
|
|
89
|
+
*/
|
|
90
|
+
function fireTriggers(path, body, query = {}, headers = {}) {
|
|
91
|
+
const matched = triggerMap.get(path);
|
|
92
|
+
if (matched) {
|
|
93
|
+
const context = { body, query, headers };
|
|
94
|
+
for (const trigger of matched) {
|
|
95
|
+
executeActions(trigger, context).catch(err => {
|
|
96
|
+
console.error(`[TRIGGER] ${trigger.name}: unhandled error - ${err.message}`);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { triggerMap, fireTriggers };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { loadTriggers };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const paths = require('../paths');
|
|
4
|
+
|
|
5
|
+
const INCLUDE_PATTERN = /\{\{([^}]+\.md)\}\}/g;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Render a markdown file, resolving {{filepath}} includes recursively.
|
|
9
|
+
* Referenced file paths resolve relative to the project root.
|
|
10
|
+
* @param {string} filePath - Absolute path to the markdown file
|
|
11
|
+
* @param {string[]} [chain=[]] - Already-resolved file paths (for circular detection)
|
|
12
|
+
* @returns {string} Rendered markdown content
|
|
13
|
+
*/
|
|
14
|
+
function render_md(filePath, chain = []) {
|
|
15
|
+
const resolved = path.resolve(filePath);
|
|
16
|
+
|
|
17
|
+
if (chain.includes(resolved)) {
|
|
18
|
+
const cycle = [...chain, resolved].map((p) => path.relative(paths.PROJECT_ROOT, p)).join(' -> ');
|
|
19
|
+
console.log(`[render_md] Circular include detected: ${cycle}`);
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!fs.existsSync(resolved)) {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
28
|
+
const currentChain = [...chain, resolved];
|
|
29
|
+
|
|
30
|
+
return content.replace(INCLUDE_PATTERN, (match, includePath) => {
|
|
31
|
+
const includeResolved = path.resolve(paths.PROJECT_ROOT, includePath.trim());
|
|
32
|
+
if (!fs.existsSync(includeResolved)) {
|
|
33
|
+
return match;
|
|
34
|
+
}
|
|
35
|
+
return render_md(includeResolved, currentChain);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { render_md };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "thepopebot",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"thepopebot": "./bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "api/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
"./api": "./api/index.js",
|
|
11
|
+
"./config": "./config/index.js",
|
|
12
|
+
"./instrumentation": "./config/instrumentation.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"lib/",
|
|
17
|
+
"api/",
|
|
18
|
+
"config/",
|
|
19
|
+
"setup/",
|
|
20
|
+
"docker/",
|
|
21
|
+
"pi/",
|
|
22
|
+
"templates/"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "echo \"No tests yet\" && exit 0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"ai",
|
|
29
|
+
"agent",
|
|
30
|
+
"autonomous",
|
|
31
|
+
"telegram",
|
|
32
|
+
"bot",
|
|
33
|
+
"claude",
|
|
34
|
+
"nextjs"
|
|
35
|
+
],
|
|
36
|
+
"author": "Stephen Pope",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@grammyjs/parse-mode": "^2.2.0",
|
|
40
|
+
"dotenv": "^16.3.1",
|
|
41
|
+
"grammy": "^1.39.3",
|
|
42
|
+
"node-cron": "^3.0.3",
|
|
43
|
+
"uuid": "^9.0.0",
|
|
44
|
+
"chalk": "^5.3.0",
|
|
45
|
+
"inquirer": "^9.2.12",
|
|
46
|
+
"open": "^10.0.0",
|
|
47
|
+
"ora": "^8.0.1"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"next": ">=15.0.0",
|
|
51
|
+
"react": ">=19.0.0",
|
|
52
|
+
"react-dom": ">=19.0.0"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=18.0.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env Sanitizer Extension - Protects credentials from AI agent access
|
|
3
|
+
*
|
|
4
|
+
* Uses Pi's spawnHook to filter sensitive env vars from bash subprocess calls
|
|
5
|
+
* while keeping them available in the main process for:
|
|
6
|
+
* - Anthropic SDK (needs ANTHROPIC_API_KEY at init)
|
|
7
|
+
* - GitHub CLI (needs GH_TOKEN)
|
|
8
|
+
* - Other extensions that may need credentials
|
|
9
|
+
*
|
|
10
|
+
* Dynamically filters all keys defined in the SECRETS JSON env var.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import { createBashTool } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
// Parse SECRETS JSON to get list of keys to filter
|
|
17
|
+
function getSecretKeys(): string[] {
|
|
18
|
+
const keys: string[] = [];
|
|
19
|
+
if (process.env.SECRETS) {
|
|
20
|
+
try {
|
|
21
|
+
const secrets = JSON.parse(process.env.SECRETS);
|
|
22
|
+
keys.push(...Object.keys(secrets));
|
|
23
|
+
} catch {
|
|
24
|
+
// Invalid JSON, ignore
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Always filter SECRETS itself
|
|
28
|
+
keys.push("SECRETS");
|
|
29
|
+
return [...new Set(keys)]; // Dedupe
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function (pi: ExtensionAPI) {
|
|
33
|
+
const secretKeys = getSecretKeys();
|
|
34
|
+
|
|
35
|
+
// Override bash tool with filtered environment for subprocesses
|
|
36
|
+
const bashTool = createBashTool(process.cwd(), {
|
|
37
|
+
spawnHook: ({ command, cwd, env }) => {
|
|
38
|
+
// Filter all secret keys from subprocess environment
|
|
39
|
+
const filteredEnv = { ...env };
|
|
40
|
+
for (const key of secretKeys) {
|
|
41
|
+
delete filteredEnv[key];
|
|
42
|
+
}
|
|
43
|
+
return { command, cwd, env: filteredEnv };
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
pi.registerTool(bashTool);
|
|
48
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: llm-secrets
|
|
3
|
+
description: List available LLM-accessible credentials. Use when you need API keys, passwords, or other secrets that have been made available to you.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# List Available Secrets
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
/job/.pi/skills/llm-secrets/llm-secrets.js
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Shows the names of available secret keys (not values). Output example:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Available secrets:
|
|
16
|
+
- BROWSER_PASSWORD
|
|
17
|
+
- SOME_API_KEY
|
|
18
|
+
|
|
19
|
+
To get a value: echo $KEY_NAME
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Get a Secret Value
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
echo $KEY_NAME
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Replace `KEY_NAME` with one of the available secret names.
|
|
29
|
+
|
|
30
|
+
## When to Use
|
|
31
|
+
|
|
32
|
+
- When a skill or tool needs authentication credentials
|
|
33
|
+
- When logging into a website via browser tools
|
|
34
|
+
- When calling an external API that requires a key
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* llm-secrets.js - List available LLM-accessible secret keys
|
|
5
|
+
*
|
|
6
|
+
* Usage: llm-secrets.js
|
|
7
|
+
*
|
|
8
|
+
* Lists the key names from LLM_SECRETS (not the values).
|
|
9
|
+
* To get a value, use: echo $KEY_NAME
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const secretsBase64 = process.env.LLM_SECRETS;
|
|
13
|
+
|
|
14
|
+
if (!secretsBase64) {
|
|
15
|
+
console.log('No LLM_SECRETS configured.');
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const decoded = Buffer.from(secretsBase64, 'base64').toString('utf-8');
|
|
21
|
+
const parsed = JSON.parse(decoded);
|
|
22
|
+
const keys = Object.keys(parsed);
|
|
23
|
+
|
|
24
|
+
if (keys.length === 0) {
|
|
25
|
+
console.log('LLM_SECRETS is empty.');
|
|
26
|
+
} else {
|
|
27
|
+
console.log('Available secrets:');
|
|
28
|
+
keys.forEach(key => console.log(` - ${key}`));
|
|
29
|
+
console.log('\nTo get a value: echo $KEY_NAME');
|
|
30
|
+
}
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.error('Error parsing LLM_SECRETS:', e.message);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|