@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.
@@ -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;
@@ -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 aider custom" -- \${cur}) )
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 aider custom"
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 {};
@@ -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
- const url = new URL("/api/tasks", config.apiUrl);
159
- const res = await fetch(url.toString(), {
160
- headers: {
161
- "x-api-key": config.apiKey,
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 (res.ok) {
165
- console.log(`API connection successful (API URL: ${config.apiUrl})`);
166
- }
167
- else if (res.status === 401) {
168
- console.error(`API connection failed: Unauthorized (HTTP 401)`);
169
- console.error(" Check your API key with: husky config set api-key <key>");
170
- process.exit(1);
171
- }
172
- else if (res.status === 403) {
173
- console.error(`API connection failed: Forbidden (HTTP 403)`);
174
- console.error(" Your API key may not have the required permissions");
175
- process.exit(1);
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
- else {
178
- console.error(`API connection failed: HTTP ${res.status}`);
179
- try {
180
- const body = await res.json();
181
- if (body.error) {
182
- console.error(` Error: ${body.error}`);
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
- catch {
186
- // Ignore JSON parse errors
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
@@ -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}`;
@@ -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.");