@simonfestl/husky-cli 1.2.0 → 1.3.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/dist/commands/agent-msg.d.ts +2 -0
- package/dist/commands/agent-msg.js +252 -0
- package/dist/commands/agent.js +270 -0
- package/dist/commands/biz/gotess.d.ts +3 -0
- package/dist/commands/biz/gotess.js +320 -0
- package/dist/commands/biz.js +5 -1
- package/dist/commands/completion.js +2 -2
- package/dist/commands/config.d.ts +27 -0
- package/dist/commands/config.js +117 -28
- package/dist/commands/interactive/vm-sessions.js +0 -1
- package/dist/commands/llm-context.js +27 -0
- package/dist/commands/preview.js +1 -1
- package/dist/commands/task.js +3 -0
- package/dist/commands/vm.js +7 -2
- package/dist/index.js +2 -0
- package/dist/lib/biz/gotess.d.ts +97 -0
- package/dist/lib/biz/gotess.js +202 -0
- package/dist/lib/permissions.d.ts +78 -0
- package/dist/lib/permissions.js +139 -0
- package/package.json +1 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { GotessClient } from "../../lib/biz/gotess.js";
|
|
3
|
+
import { getConfig, setGotessConfig } from "../config.js";
|
|
4
|
+
import * as readline from "readline";
|
|
5
|
+
function prompt(question) {
|
|
6
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
rl.question(question, (answer) => {
|
|
9
|
+
rl.close();
|
|
10
|
+
resolve(answer.trim());
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
async function pollSmsCode(apiUrl, afterTimestamp, maxRetries = 3) {
|
|
15
|
+
for (let retry = 0; retry < maxRetries; retry++) {
|
|
16
|
+
if (retry > 0) {
|
|
17
|
+
console.log(`\n Retry ${retry}/${maxRetries - 1}...`);
|
|
18
|
+
}
|
|
19
|
+
await new Promise(r => setTimeout(r, 60000));
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(`${apiUrl}/api/webhooks/sms/latest?after=${afterTimestamp}`);
|
|
22
|
+
const data = await res.json();
|
|
23
|
+
if (data.code) {
|
|
24
|
+
return data.code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// ignore fetch errors
|
|
29
|
+
}
|
|
30
|
+
process.stdout.write(".");
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
export const gotessCommand = new Command("gotess")
|
|
35
|
+
.description("Manage accounting (Gotess/Tess)");
|
|
36
|
+
gotessCommand
|
|
37
|
+
.command("login")
|
|
38
|
+
.description("Login to Gotess with 2FA")
|
|
39
|
+
.option("-e, --email <email>", "Email address")
|
|
40
|
+
.option("-p, --password <password>", "Password")
|
|
41
|
+
.action(async (options) => {
|
|
42
|
+
try {
|
|
43
|
+
const client = new GotessClient();
|
|
44
|
+
const email = options.email || await prompt("Email: ");
|
|
45
|
+
const password = options.password || await prompt("Password: ");
|
|
46
|
+
console.log("\n Logging in...");
|
|
47
|
+
const loginResult = await client.login(email, password);
|
|
48
|
+
if (loginResult.user.factors && loginResult.user.factors.length > 0) {
|
|
49
|
+
const factor = loginResult.user.factors[0];
|
|
50
|
+
console.log(` 2FA required (${factor.factor_type})`);
|
|
51
|
+
const challenge = await client.startMfaChallenge(factor.id);
|
|
52
|
+
const smsRequestTime = Date.now();
|
|
53
|
+
console.log(" SMS sent! Waiting for code via webhook");
|
|
54
|
+
const config = getConfig();
|
|
55
|
+
const apiUrl = config.apiUrl || "https://husky-api-474966775596.europe-west1.run.app";
|
|
56
|
+
const code = await pollSmsCode(apiUrl, smsRequestTime);
|
|
57
|
+
if (!code) {
|
|
58
|
+
console.error("\n ✗ Timeout waiting for SMS code");
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
console.log(`\n ✓ Code received: ${code}`);
|
|
62
|
+
const verified = await client.verifyMfa(factor.id, challenge.id, code);
|
|
63
|
+
setGotessConfig('gotessToken', verified.access_token);
|
|
64
|
+
console.log(" ✓ Login successful (with 2FA)");
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
setGotessConfig('gotessToken', loginResult.access_token);
|
|
68
|
+
console.log("\n ✓ Login successful");
|
|
69
|
+
}
|
|
70
|
+
const books = await client.listBooks();
|
|
71
|
+
if (books.length > 0) {
|
|
72
|
+
console.log("\n Available books:");
|
|
73
|
+
books.forEach((b, i) => console.log(` ${i + 1}. ${b.name} (${b.id})`));
|
|
74
|
+
if (books.length === 1) {
|
|
75
|
+
setGotessConfig('gotessBookId', books[0].id);
|
|
76
|
+
console.log(`\n ✓ Auto-selected: ${books[0].name}`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const choice = await prompt("\n Select book (number): ");
|
|
80
|
+
const idx = parseInt(choice, 10) - 1;
|
|
81
|
+
if (idx >= 0 && idx < books.length) {
|
|
82
|
+
setGotessConfig('gotessBookId', books[idx].id);
|
|
83
|
+
console.log(` ✓ Selected: ${books[idx].name}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
console.log("");
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
console.error("Error:", error.message);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
gotessCommand
|
|
95
|
+
.command("missing")
|
|
96
|
+
.description("List transactions with missing invoices")
|
|
97
|
+
.option("-l, --limit <num>", "Limit results", "50")
|
|
98
|
+
.option("--json", "Output as JSON")
|
|
99
|
+
.action(async (options) => {
|
|
100
|
+
try {
|
|
101
|
+
const client = GotessClient.fromConfig();
|
|
102
|
+
const transactions = await client.getMissingInvoices();
|
|
103
|
+
if (options.json) {
|
|
104
|
+
console.log(JSON.stringify(transactions, null, 2));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
console.log(`\n 📋 Missing Invoices (${transactions.length})\n`);
|
|
108
|
+
if (transactions.length === 0) {
|
|
109
|
+
console.log(" No missing invoices!");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
let total = 0;
|
|
113
|
+
for (const tx of transactions.slice(0, parseInt(options.limit, 10))) {
|
|
114
|
+
const date = tx.value_date?.slice(0, 10) || "?";
|
|
115
|
+
const amount = tx.amount || 0;
|
|
116
|
+
const name = (tx.counterpart_name || tx.purpose || "Unknown").slice(0, 35);
|
|
117
|
+
total += Math.abs(amount);
|
|
118
|
+
console.log(` ${date} │ ${amount.toFixed(2).padStart(10)} EUR │ ${name}`);
|
|
119
|
+
}
|
|
120
|
+
console.log(`\n Total: ${total.toFixed(2)} EUR\n`);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
console.error("Error:", error.message);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
gotessCommand
|
|
128
|
+
.command("transactions")
|
|
129
|
+
.description("List transactions")
|
|
130
|
+
.option("-s, --status <status>", "Filter by status (invoice_missing, invoice_linked, proofless_auto)")
|
|
131
|
+
.option("-l, --limit <num>", "Limit results", "30")
|
|
132
|
+
.option("--json", "Output as JSON")
|
|
133
|
+
.action(async (options) => {
|
|
134
|
+
try {
|
|
135
|
+
const client = GotessClient.fromConfig();
|
|
136
|
+
const transactions = await client.listTransactions({
|
|
137
|
+
status: options.status,
|
|
138
|
+
limit: parseInt(options.limit, 10),
|
|
139
|
+
});
|
|
140
|
+
if (options.json) {
|
|
141
|
+
console.log(JSON.stringify(transactions, null, 2));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log(`\n 📊 Transactions (${transactions.length})\n`);
|
|
145
|
+
for (const tx of transactions) {
|
|
146
|
+
const date = tx.value_date?.slice(0, 10) || "?";
|
|
147
|
+
const amount = tx.amount || 0;
|
|
148
|
+
const name = (tx.counterpart_name || "?").slice(0, 30);
|
|
149
|
+
const status = tx.transaction_status || "-";
|
|
150
|
+
const marker = tx.invoice_id ? "✓" : "✗";
|
|
151
|
+
console.log(` ${marker} ${date} │ ${amount.toFixed(2).padStart(10)} EUR │ ${name.padEnd(30)} │ ${status}`);
|
|
152
|
+
}
|
|
153
|
+
console.log("");
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.error("Error:", error.message);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
gotessCommand
|
|
161
|
+
.command("invoices")
|
|
162
|
+
.description("List invoices/receipts")
|
|
163
|
+
.option("-s, --search <term>", "Search by sender or filename")
|
|
164
|
+
.option("-l, --limit <num>", "Limit results", "30")
|
|
165
|
+
.option("--json", "Output as JSON")
|
|
166
|
+
.action(async (options) => {
|
|
167
|
+
try {
|
|
168
|
+
const client = GotessClient.fromConfig();
|
|
169
|
+
const invoices = await client.listInvoices({
|
|
170
|
+
search: options.search,
|
|
171
|
+
limit: parseInt(options.limit, 10),
|
|
172
|
+
});
|
|
173
|
+
if (options.json) {
|
|
174
|
+
console.log(JSON.stringify(invoices, null, 2));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
console.log(`\n 🧾 Invoices (${invoices.length})\n`);
|
|
178
|
+
for (const inv of invoices) {
|
|
179
|
+
const date = inv.invoice_date?.slice(0, 10) || "?";
|
|
180
|
+
const amount = inv.amount?.toFixed(2).padStart(10) || "?".padStart(10);
|
|
181
|
+
const sender = (inv.sender_name || "?").slice(0, 25);
|
|
182
|
+
const file = (inv.filename || "").slice(0, 40);
|
|
183
|
+
console.log(` ${date} │ ${amount} EUR │ ${sender.padEnd(25)} │ ${file}`);
|
|
184
|
+
}
|
|
185
|
+
console.log("");
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
console.error("Error:", error.message);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
gotessCommand
|
|
193
|
+
.command("match")
|
|
194
|
+
.description("Auto-match invoices to transactions")
|
|
195
|
+
.option("--dry-run", "Show matches without applying")
|
|
196
|
+
.option("--json", "Output as JSON")
|
|
197
|
+
.action(async (options) => {
|
|
198
|
+
try {
|
|
199
|
+
const client = GotessClient.fromConfig();
|
|
200
|
+
console.log("\n 🔍 Analyzing transactions and invoices...\n");
|
|
201
|
+
const result = await client.autoMatch();
|
|
202
|
+
if (options.json) {
|
|
203
|
+
console.log(JSON.stringify(result, null, 2));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (result.matched.length > 0) {
|
|
207
|
+
console.log(` ✓ Found ${result.matched.length} matches:\n`);
|
|
208
|
+
for (const { transaction, invoice } of result.matched) {
|
|
209
|
+
const txName = (transaction.counterpart_name || "?").slice(0, 25);
|
|
210
|
+
const invName = (invoice.sender_name || invoice.filename || "?").slice(0, 25);
|
|
211
|
+
console.log(` ${transaction.value_date?.slice(0, 10)} │ ${transaction.amount.toFixed(2)} EUR │ ${txName}`);
|
|
212
|
+
console.log(` → ${invName}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (result.unmatched.length > 0) {
|
|
216
|
+
console.log(`\n ✗ ${result.unmatched.length} unmatched transactions`);
|
|
217
|
+
}
|
|
218
|
+
if (!options.dryRun && result.matched.length > 0) {
|
|
219
|
+
const confirm = await prompt("\n Apply matches? (y/n): ");
|
|
220
|
+
if (confirm.toLowerCase() === 'y') {
|
|
221
|
+
for (const { transaction, invoice } of result.matched) {
|
|
222
|
+
await client.linkInvoice(transaction.id, invoice.id);
|
|
223
|
+
}
|
|
224
|
+
console.log(`\n ✓ Linked ${result.matched.length} invoices`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
console.log("");
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
console.error("Error:", error.message);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
gotessCommand
|
|
235
|
+
.command("link <transactionId> <invoiceId>")
|
|
236
|
+
.description("Manually link an invoice to a transaction")
|
|
237
|
+
.action(async (transactionId, invoiceId) => {
|
|
238
|
+
try {
|
|
239
|
+
const client = GotessClient.fromConfig();
|
|
240
|
+
await client.linkInvoice(transactionId, invoiceId);
|
|
241
|
+
console.log(`\n ✓ Linked invoice ${invoiceId} to transaction ${transactionId}\n`);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
console.error("Error:", error.message);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
gotessCommand
|
|
249
|
+
.command("proofless <transactionId>")
|
|
250
|
+
.description("Mark a transaction as proofless (no invoice needed)")
|
|
251
|
+
.action(async (transactionId) => {
|
|
252
|
+
try {
|
|
253
|
+
const client = GotessClient.fromConfig();
|
|
254
|
+
await client.markProofless(transactionId);
|
|
255
|
+
console.log(`\n ✓ Transaction ${transactionId} marked as proofless\n`);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
console.error("Error:", error.message);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
gotessCommand
|
|
263
|
+
.command("accounts")
|
|
264
|
+
.description("List connected bank accounts")
|
|
265
|
+
.option("--json", "Output as JSON")
|
|
266
|
+
.action(async (options) => {
|
|
267
|
+
try {
|
|
268
|
+
const client = GotessClient.fromConfig();
|
|
269
|
+
const accounts = await client.listAccounts();
|
|
270
|
+
if (options.json) {
|
|
271
|
+
console.log(JSON.stringify(accounts, null, 2));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
console.log(`\n 🏦 Bank Accounts (${accounts.length})\n`);
|
|
275
|
+
for (const acc of accounts) {
|
|
276
|
+
const name = acc.account_name || "Unknown";
|
|
277
|
+
const iban = acc.iban || "-";
|
|
278
|
+
const type = acc.account_type || "-";
|
|
279
|
+
console.log(` ${acc.finapi_account_id} │ ${name.padEnd(20)} │ ${iban} │ ${type}`);
|
|
280
|
+
}
|
|
281
|
+
console.log("");
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
console.error("Error:", error.message);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
gotessCommand
|
|
289
|
+
.command("status")
|
|
290
|
+
.description("Show current Gotess session status")
|
|
291
|
+
.action(async () => {
|
|
292
|
+
try {
|
|
293
|
+
const config = getConfig();
|
|
294
|
+
const token = config.gotessToken;
|
|
295
|
+
const bookId = config.gotessBookId;
|
|
296
|
+
console.log("\n Gotess Status");
|
|
297
|
+
console.log(" " + "─".repeat(40));
|
|
298
|
+
console.log(` Logged in: ${token ? "Yes" : "No"}`);
|
|
299
|
+
console.log(` Book ID: ${bookId || "Not set"}`);
|
|
300
|
+
if (token) {
|
|
301
|
+
const client = new GotessClient(token, bookId);
|
|
302
|
+
try {
|
|
303
|
+
const books = await client.listBooks();
|
|
304
|
+
const book = books.find(b => b.id === bookId);
|
|
305
|
+
if (book) {
|
|
306
|
+
console.log(` Book Name: ${book.name}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
console.log(" Session: Expired (re-login required)");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
console.log("");
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
console.error("Error:", error.message);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
export default gotessCommand;
|
package/dist/commands/biz.js
CHANGED
|
@@ -11,12 +11,16 @@ import { ticketsCommand } from "./biz/tickets.js";
|
|
|
11
11
|
import { customersCommand } from "./biz/customers.js";
|
|
12
12
|
import { seatableCommand } from "./biz/seatable.js";
|
|
13
13
|
import { qdrantCommand } from "./biz/qdrant.js";
|
|
14
|
+
import { gotessCommand } from "./biz/gotess.js";
|
|
15
|
+
import { guards } from "../lib/permissions.js";
|
|
14
16
|
export const bizCommand = new Command("biz")
|
|
15
17
|
.description("Business operations for autonomous agents")
|
|
18
|
+
.hook("preAction", guards.bizAccess) // RBAC: Require biz permissions
|
|
16
19
|
.addCommand(ordersCommand)
|
|
17
20
|
.addCommand(productsCommand)
|
|
18
21
|
.addCommand(ticketsCommand)
|
|
19
22
|
.addCommand(customersCommand)
|
|
20
23
|
.addCommand(seatableCommand)
|
|
21
|
-
.addCommand(qdrantCommand)
|
|
24
|
+
.addCommand(qdrantCommand)
|
|
25
|
+
.addCommand(gotessCommand);
|
|
22
26
|
export default bizCommand;
|
|
@@ -135,7 +135,7 @@ ${subcommandCases}
|
|
|
135
135
|
return 0
|
|
136
136
|
;;
|
|
137
137
|
--agent)
|
|
138
|
-
COMPREPLY=( $(compgen -W "claude-code gemini-cli
|
|
138
|
+
COMPREPLY=( $(compgen -W "claude-code gemini-cli custom" -- \${cur}) )
|
|
139
139
|
return 0
|
|
140
140
|
;;
|
|
141
141
|
--type)
|
|
@@ -268,7 +268,7 @@ complete -c husky -l project -d "Project ID" -r
|
|
|
268
268
|
complete -c husky -l status -d "Filter by status" -r -a "backlog in_progress review done pending running completed failed"
|
|
269
269
|
complete -c husky -l priority -d "Priority level" -r -a "low medium high urgent must should could wont"
|
|
270
270
|
complete -c husky -l assignee -d "Assignee type" -r -a "human llm unassigned"
|
|
271
|
-
complete -c husky -l agent -d "Agent type" -r -a "claude-code gemini-cli
|
|
271
|
+
complete -c husky -l agent -d "Agent type" -r -a "claude-code gemini-cli custom"
|
|
272
272
|
complete -c husky -l type -d "Type filter" -r -a "global project architecture patterns decisions learnings"
|
|
273
273
|
complete -c husky -l value-stream -d "Value stream" -r -a "order_to_delivery procure_to_pay returns_management product_lifecycle customer_service marketing_sales finance_accounting hr_operations it_operations general"
|
|
274
274
|
complete -c husky -l action -d "Action type" -r -a "manual semi_automated fully_automated"
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
type AgentRole = "supervisor" | "worker" | "reviewer" | "e2e_agent" | "pr_agent" | "support";
|
|
2
3
|
interface Config {
|
|
3
4
|
apiUrl?: string;
|
|
4
5
|
apiKey?: string;
|
|
5
6
|
workerId?: string;
|
|
6
7
|
workerName?: string;
|
|
8
|
+
role?: AgentRole;
|
|
9
|
+
permissions?: string[];
|
|
10
|
+
roleLastChecked?: string;
|
|
7
11
|
billbeeApiKey?: string;
|
|
8
12
|
billbeeUsername?: string;
|
|
9
13
|
billbeePassword?: string;
|
|
@@ -17,8 +21,31 @@ interface Config {
|
|
|
17
21
|
qdrantApiKey?: string;
|
|
18
22
|
gcpProjectId?: string;
|
|
19
23
|
gcpLocation?: string;
|
|
24
|
+
gotessToken?: string;
|
|
25
|
+
gotessBookId?: string;
|
|
20
26
|
}
|
|
21
27
|
export declare function getConfig(): Config;
|
|
28
|
+
/**
|
|
29
|
+
* Fetch role and permissions from /api/auth/whoami
|
|
30
|
+
* Caches the result in config for 1 hour
|
|
31
|
+
*/
|
|
32
|
+
export declare function fetchAndCacheRole(): Promise<{
|
|
33
|
+
role?: AgentRole;
|
|
34
|
+
permissions?: string[];
|
|
35
|
+
}>;
|
|
36
|
+
/**
|
|
37
|
+
* Check if current config has a specific permission
|
|
38
|
+
*/
|
|
39
|
+
export declare function hasPermission(permission: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Get current role from config (may be undefined if not fetched)
|
|
42
|
+
*/
|
|
43
|
+
export declare function getRole(): AgentRole | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Clear the role cache to force a refresh on next fetchAndCacheRole call
|
|
46
|
+
*/
|
|
47
|
+
export declare function clearRoleCache(): void;
|
|
22
48
|
export declare function setConfig(key: "apiUrl" | "apiKey" | "workerId" | "workerName", value: string): void;
|
|
49
|
+
export declare function setGotessConfig(token: string, bookId: string): void;
|
|
23
50
|
export declare const configCommand: Command;
|
|
24
51
|
export {};
|
package/dist/commands/config.js
CHANGED
|
@@ -46,12 +46,86 @@ function saveConfig(config) {
|
|
|
46
46
|
}
|
|
47
47
|
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Fetch role and permissions from /api/auth/whoami
|
|
51
|
+
* Caches the result in config for 1 hour
|
|
52
|
+
*/
|
|
53
|
+
export async function fetchAndCacheRole() {
|
|
54
|
+
const config = getConfig();
|
|
55
|
+
// Check if we have cached role that's less than 1 hour old
|
|
56
|
+
if (config.role && config.roleLastChecked) {
|
|
57
|
+
const lastChecked = new Date(config.roleLastChecked);
|
|
58
|
+
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
|
59
|
+
if (lastChecked > oneHourAgo) {
|
|
60
|
+
return { role: config.role, permissions: config.permissions };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Fetch fresh role/permissions
|
|
64
|
+
if (!config.apiUrl || !config.apiKey) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const url = new URL("/api/auth/whoami", config.apiUrl);
|
|
69
|
+
const res = await fetch(url.toString(), {
|
|
70
|
+
headers: { "x-api-key": config.apiKey },
|
|
71
|
+
});
|
|
72
|
+
if (res.ok) {
|
|
73
|
+
const data = await res.json();
|
|
74
|
+
// Update config cache
|
|
75
|
+
config.role = data.role;
|
|
76
|
+
config.permissions = data.permissions;
|
|
77
|
+
config.roleLastChecked = new Date().toISOString();
|
|
78
|
+
saveConfig(config);
|
|
79
|
+
return { role: data.role, permissions: data.permissions };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Ignore fetch errors, return cached or empty
|
|
84
|
+
}
|
|
85
|
+
return { role: config.role, permissions: config.permissions };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Check if current config has a specific permission
|
|
89
|
+
*/
|
|
90
|
+
export function hasPermission(permission) {
|
|
91
|
+
const config = getConfig();
|
|
92
|
+
if (!config.permissions)
|
|
93
|
+
return false;
|
|
94
|
+
// Direct match
|
|
95
|
+
if (config.permissions.includes(permission))
|
|
96
|
+
return true;
|
|
97
|
+
// Wildcard match (e.g., "task:*" matches "task:read")
|
|
98
|
+
const [resource] = permission.split(":");
|
|
99
|
+
if (config.permissions.includes(`${resource}:*`))
|
|
100
|
+
return true;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Get current role from config (may be undefined if not fetched)
|
|
105
|
+
*/
|
|
106
|
+
export function getRole() {
|
|
107
|
+
return getConfig().role;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Clear the role cache to force a refresh on next fetchAndCacheRole call
|
|
111
|
+
*/
|
|
112
|
+
export function clearRoleCache() {
|
|
113
|
+
const config = getConfig();
|
|
114
|
+
delete config.roleLastChecked;
|
|
115
|
+
saveConfig(config);
|
|
116
|
+
}
|
|
49
117
|
// Helper to set a single config value (used by interactive mode and worker identity)
|
|
50
118
|
export function setConfig(key, value) {
|
|
51
119
|
const config = getConfig();
|
|
52
120
|
config[key] = value;
|
|
53
121
|
saveConfig(config);
|
|
54
122
|
}
|
|
123
|
+
export function setGotessConfig(token, bookId) {
|
|
124
|
+
const config = getConfig();
|
|
125
|
+
config.gotessToken = token;
|
|
126
|
+
config.gotessBookId = bookId;
|
|
127
|
+
saveConfig(config);
|
|
128
|
+
}
|
|
55
129
|
export const configCommand = new Command("config")
|
|
56
130
|
.description("Manage CLI configuration");
|
|
57
131
|
// husky config set <key> <value>
|
|
@@ -82,6 +156,8 @@ configCommand
|
|
|
82
156
|
// GCP
|
|
83
157
|
"gcp-project-id": "gcpProjectId",
|
|
84
158
|
"gcp-location": "gcpLocation",
|
|
159
|
+
"gotess-token": "gotessToken",
|
|
160
|
+
"gotess-book-id": "gotessBookId",
|
|
85
161
|
};
|
|
86
162
|
const configKey = keyMappings[key];
|
|
87
163
|
if (!configKey) {
|
|
@@ -93,6 +169,7 @@ configCommand
|
|
|
93
169
|
console.log(" SeaTable: seatable-api-token, seatable-server-url");
|
|
94
170
|
console.log(" Qdrant: qdrant-url, qdrant-api-key");
|
|
95
171
|
console.log(" GCP: gcp-project-id, gcp-location");
|
|
172
|
+
console.log(" Gotess: gotess-token, gotess-book-id");
|
|
96
173
|
process.exit(1);
|
|
97
174
|
}
|
|
98
175
|
// Validation for specific keys
|
|
@@ -107,7 +184,7 @@ configCommand
|
|
|
107
184
|
config[configKey] = value;
|
|
108
185
|
saveConfig(config);
|
|
109
186
|
// Mask sensitive values in output
|
|
110
|
-
const sensitiveKeys = ["api-key", "billbee-api-key", "billbee-password", "zendesk-api-token", "seatable-api-token"];
|
|
187
|
+
const sensitiveKeys = ["api-key", "billbee-api-key", "billbee-password", "zendesk-api-token", "seatable-api-token", "gotess-token"];
|
|
111
188
|
const displayValue = sensitiveKeys.includes(key) ? "***" : value;
|
|
112
189
|
console.log(`✓ Set ${key} = ${displayValue}`);
|
|
113
190
|
});
|
|
@@ -155,37 +232,49 @@ configCommand
|
|
|
155
232
|
}
|
|
156
233
|
console.log("Testing API connection...");
|
|
157
234
|
try {
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
},
|
|
235
|
+
// First test basic connectivity with /api/tasks
|
|
236
|
+
const tasksUrl = new URL("/api/tasks", config.apiUrl);
|
|
237
|
+
const tasksRes = await fetch(tasksUrl.toString(), {
|
|
238
|
+
headers: { "x-api-key": config.apiKey },
|
|
163
239
|
});
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
240
|
+
if (!tasksRes.ok) {
|
|
241
|
+
if (tasksRes.status === 401) {
|
|
242
|
+
console.error(`API connection failed: Unauthorized (HTTP 401)`);
|
|
243
|
+
console.error(" Check your API key with: husky config set api-key <key>");
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
else if (tasksRes.status === 403) {
|
|
247
|
+
console.error(`API connection failed: Forbidden (HTTP 403)`);
|
|
248
|
+
console.error(" Your API key may not have the required permissions");
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
console.error(`API connection failed: HTTP ${tasksRes.status}`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
176
255
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
256
|
+
console.log(`API connection successful (API URL: ${config.apiUrl})`);
|
|
257
|
+
// Now fetch role/permissions from whoami
|
|
258
|
+
const whoamiUrl = new URL("/api/auth/whoami", config.apiUrl);
|
|
259
|
+
const whoamiRes = await fetch(whoamiUrl.toString(), {
|
|
260
|
+
headers: { "x-api-key": config.apiKey },
|
|
261
|
+
});
|
|
262
|
+
if (whoamiRes.ok) {
|
|
263
|
+
const data = await whoamiRes.json();
|
|
264
|
+
// Cache the role/permissions
|
|
265
|
+
const updatedConfig = getConfig();
|
|
266
|
+
updatedConfig.role = data.role;
|
|
267
|
+
updatedConfig.permissions = data.permissions;
|
|
268
|
+
updatedConfig.roleLastChecked = new Date().toISOString();
|
|
269
|
+
saveConfig(updatedConfig);
|
|
270
|
+
console.log(`\nRBAC Info:`);
|
|
271
|
+
console.log(` Role: ${data.role || "(not assigned)"}`);
|
|
272
|
+
if (data.permissions && data.permissions.length > 0) {
|
|
273
|
+
console.log(` Permissions: ${data.permissions.join(", ")}`);
|
|
184
274
|
}
|
|
185
|
-
|
|
186
|
-
|
|
275
|
+
if (data.agentId) {
|
|
276
|
+
console.log(` Agent ID: ${data.agentId}`);
|
|
187
277
|
}
|
|
188
|
-
process.exit(1);
|
|
189
278
|
}
|
|
190
279
|
}
|
|
191
280
|
catch (error) {
|
|
@@ -140,7 +140,6 @@ async function createVMSession(config) {
|
|
|
140
140
|
choices: [
|
|
141
141
|
{ name: "Claude Code", value: "claude-code" },
|
|
142
142
|
{ name: "Gemini CLI", value: "gemini-cli" },
|
|
143
|
-
{ name: "Aider", value: "aider" },
|
|
144
143
|
{ name: "Custom", value: "custom" },
|
|
145
144
|
],
|
|
146
145
|
default: "claude-code",
|
|
@@ -11,6 +11,7 @@ export function generateLLMContext() {
|
|
|
11
11
|
>
|
|
12
12
|
> **DO NOT:**
|
|
13
13
|
> - Make direct API calls to Billbee, Zendesk, or other services
|
|
14
|
+
> - Make direct API calls to husky-api (use CLI instead)
|
|
14
15
|
> - Bypass Husky CLI for task management
|
|
15
16
|
> - Create custom integrations when Husky commands exist
|
|
16
17
|
>
|
|
@@ -18,6 +19,7 @@ export function generateLLMContext() {
|
|
|
18
19
|
> - Use \`husky biz\` commands for business operations
|
|
19
20
|
> - Use \`husky task\` commands for task lifecycle
|
|
20
21
|
> - Use \`husky worktree\` for Git isolation
|
|
22
|
+
> - Use \`husky chat\` commands for Google Chat communication
|
|
21
23
|
> - Check \`husky config test\` before operations
|
|
22
24
|
|
|
23
25
|
---
|
|
@@ -153,6 +155,31 @@ husky worker sessions # List active sessions
|
|
|
153
155
|
husky worker activity # Who is working on what
|
|
154
156
|
\`\`\`
|
|
155
157
|
|
|
158
|
+
### Chat (Google Chat Integration)
|
|
159
|
+
\`\`\`bash
|
|
160
|
+
husky chat reply-chat --space <space-id> "<message>" # Send message to Google Chat
|
|
161
|
+
husky chat inbox # Get messages from Google Chat
|
|
162
|
+
husky chat inbox --unread # Only unread messages
|
|
163
|
+
husky chat pending # Get pending messages from user
|
|
164
|
+
husky chat send "<message>" # Send message as supervisor
|
|
165
|
+
husky chat reply <messageId> "<response>" # Reply to specific message
|
|
166
|
+
husky chat review "<question>" # Request human review via Google Chat
|
|
167
|
+
husky chat review-status <reviewId> # Check review status
|
|
168
|
+
husky chat watch # Watch for new messages (blocking)
|
|
169
|
+
\`\`\`
|
|
170
|
+
|
|
171
|
+
### Agent Messaging (agent-to-agent communication)
|
|
172
|
+
\`\`\`bash
|
|
173
|
+
husky agent-msg send --type <type> --title "<title>" # Send message (types: approval_request, status_update, error_report, completion, query)
|
|
174
|
+
husky agent-msg list # List messages
|
|
175
|
+
husky agent-msg list --status pending # Filter by status
|
|
176
|
+
husky agent-msg pending # List pending messages (for supervisor)
|
|
177
|
+
husky agent-msg respond <id> --approve # Approve request
|
|
178
|
+
husky agent-msg respond <id> --reject # Reject request
|
|
179
|
+
husky agent-msg get <id> # Get message details
|
|
180
|
+
husky agent-msg wait <id> # Wait for response (blocking)
|
|
181
|
+
\`\`\`
|
|
182
|
+
|
|
156
183
|
### Utility Commands
|
|
157
184
|
\`\`\`bash
|
|
158
185
|
husky explain <command> # Explain CLI commands
|
package/dist/commands/preview.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Command } from "commander";
|
|
|
2
2
|
import { getConfig } from "./config.js";
|
|
3
3
|
const PREVIEW_DEPLOY_TRIGGER_ID = "80b3ba55-ae74-41cd-b7d0-a477ecc357b1";
|
|
4
4
|
const PREVIEW_CLEANUP_TRIGGER_ID = "965c3e86-677f-4063-b391-43019f621ea2";
|
|
5
|
-
const GCP_PROJECT = "tigerv0";
|
|
5
|
+
const GCP_PROJECT = process.env.GCP_PROJECT_ID || process.env.GOOGLE_CLOUD_PROJECT || "tigerv0";
|
|
6
6
|
async function apiRequest(method, path, body) {
|
|
7
7
|
const config = getConfig();
|
|
8
8
|
const url = `${config.apiUrl}${path}`;
|
package/dist/commands/task.js
CHANGED
|
@@ -7,6 +7,7 @@ import { ensureWorkerRegistered, generateSessionId, registerSession } from "../l
|
|
|
7
7
|
import { WorktreeManager } from "../lib/worktree.js";
|
|
8
8
|
import { execSync } from "child_process";
|
|
9
9
|
import { resolveProject, fetchProjects, formatProjectList } from "../lib/project-resolver.js";
|
|
10
|
+
import { requirePermission } from "../lib/permissions.js";
|
|
10
11
|
export const taskCommand = new Command("task")
|
|
11
12
|
.description("Manage tasks");
|
|
12
13
|
// Helper: Get task ID from --id flag or HUSKY_TASK_ID env var
|
|
@@ -284,6 +285,8 @@ taskCommand
|
|
|
284
285
|
.option("--pr <url>", "Link to PR")
|
|
285
286
|
.option("--skip-qa", "Skip QA review and mark as done directly")
|
|
286
287
|
.action(async (id, options) => {
|
|
288
|
+
// RBAC: Only supervisor and pr_agent can set tasks to done
|
|
289
|
+
requirePermission("task:done");
|
|
287
290
|
const config = getConfig();
|
|
288
291
|
if (!config.apiUrl) {
|
|
289
292
|
console.error("Error: API URL not configured.");
|