@simonfestl/husky-cli 1.38.4 → 1.38.6

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,123 @@
1
+ import { getConfig } from "../../commands/config.js";
2
+ export class XClient {
3
+ config;
4
+ baseUrl = 'https://api.twitter.com/2';
5
+ userIdCache = new Map();
6
+ constructor(config) {
7
+ this.config = config;
8
+ }
9
+ static fromConfig() {
10
+ const config = getConfig();
11
+ const env = process.env.HUSKY_ENV || 'PROD';
12
+ const bearerToken = process.env[`${env}_X_BEARER_TOKEN`] || process.env.X_BEARER_TOKEN || config.xBearerToken;
13
+ if (!bearerToken) {
14
+ return null;
15
+ }
16
+ return new XClient({ bearerToken });
17
+ }
18
+ async makeRequest(endpoint, params, retryCount = 0) {
19
+ const url = new URL(`${this.baseUrl}${endpoint}`);
20
+ Object.entries(params).forEach(([key, value]) => {
21
+ url.searchParams.append(key, value);
22
+ });
23
+ const timeout = 15000;
24
+ const controller = new AbortController();
25
+ const id = setTimeout(() => controller.abort(), timeout);
26
+ try {
27
+ const response = await fetch(url.toString(), {
28
+ headers: {
29
+ 'Authorization': `Bearer ${this.config.bearerToken}`,
30
+ },
31
+ signal: controller.signal,
32
+ });
33
+ clearTimeout(id);
34
+ if (response.status === 429 && retryCount < 2) {
35
+ const resetTime = parseInt(response.headers.get('x-rate-limit-reset') || '0', 10);
36
+ const waitTime = resetTime ? Math.max(0, (resetTime * 1000) - Date.now() + 1000) : 5000;
37
+ // Cap wait time to 30s for CLI
38
+ if (waitTime < 30000) {
39
+ await new Promise(resolve => setTimeout(resolve, waitTime));
40
+ return this.makeRequest(endpoint, params, retryCount + 1);
41
+ }
42
+ }
43
+ if (!response.ok) {
44
+ const error = await response.text();
45
+ // Redact token if it somehow ended up in the error text
46
+ const redactedError = error.replace(this.config.bearerToken, '[REDACTED]');
47
+ throw new Error(`X API error: ${response.status} ${response.statusText} - ${redactedError}`);
48
+ }
49
+ return response.json();
50
+ }
51
+ catch (err) {
52
+ clearTimeout(id);
53
+ throw err;
54
+ }
55
+ }
56
+ async searchRecentTweets(query, maxResults = 10) {
57
+ const clampedResults = Math.max(10, Math.min(maxResults, 100));
58
+ const data = await this.makeRequest('/tweets/search/recent', {
59
+ query,
60
+ max_results: clampedResults.toString(),
61
+ 'tweet.fields': 'created_at,public_metrics,author_id',
62
+ expansions: 'author_id',
63
+ 'user.fields': 'username',
64
+ });
65
+ if (!data.data)
66
+ return [];
67
+ const users = new Map(data.includes?.users?.map(u => [u.id, u.username]) || []);
68
+ return data.data.map(tweet => ({
69
+ id: tweet.id,
70
+ text: tweet.text,
71
+ authorId: tweet.author_id,
72
+ username: users.get(tweet.author_id) || 'unknown',
73
+ createdAt: tweet.created_at,
74
+ publicMetrics: {
75
+ retweetCount: tweet.public_metrics?.retweet_count ?? 0,
76
+ replyCount: tweet.public_metrics?.reply_count ?? 0,
77
+ likeCount: tweet.public_metrics?.like_count ?? 0,
78
+ quoteCount: tweet.public_metrics?.quote_count ?? 0,
79
+ },
80
+ }));
81
+ }
82
+ async getUserTweets(username, maxResults = 10) {
83
+ const clampedResults = Math.max(5, Math.min(maxResults, 100));
84
+ const cleanUsername = encodeURIComponent(username.replace('@', ''));
85
+ // 1. Resolve username to ID (with cache)
86
+ let userId = this.userIdCache.get(cleanUsername);
87
+ if (!userId) {
88
+ const userData = await this.makeRequest(`/users/by/username/${cleanUsername}`, {});
89
+ if (!userData.data)
90
+ return [];
91
+ userId = userData.data.id;
92
+ this.userIdCache.set(cleanUsername, userId);
93
+ }
94
+ // 2. Get tweets
95
+ const data = await this.makeRequest(`/users/${encodeURIComponent(userId)}/tweets`, {
96
+ max_results: clampedResults.toString(),
97
+ 'tweet.fields': 'created_at,public_metrics,author_id',
98
+ expansions: 'author_id',
99
+ 'user.fields': 'username',
100
+ });
101
+ if (!data.data)
102
+ return [];
103
+ return data.data.map(tweet => ({
104
+ id: tweet.id,
105
+ text: tweet.text,
106
+ authorId: tweet.author_id,
107
+ username: username,
108
+ createdAt: tweet.created_at,
109
+ publicMetrics: {
110
+ retweetCount: tweet.public_metrics?.retweet_count ?? 0,
111
+ replyCount: tweet.public_metrics?.reply_count ?? 0,
112
+ likeCount: tweet.public_metrics?.like_count ?? 0,
113
+ quoteCount: tweet.public_metrics?.quote_count ?? 0,
114
+ },
115
+ }));
116
+ }
117
+ }
118
+ export function isXConfigured() {
119
+ const config = getConfig();
120
+ const env = process.env.HUSKY_ENV || 'PROD';
121
+ const bearerToken = process.env[`${env}_X_BEARER_TOKEN`] || process.env.X_BEARER_TOKEN || config.xBearerToken;
122
+ return !!bearerToken;
123
+ }
@@ -0,0 +1,72 @@
1
+ export interface YouTubeConfig {
2
+ apiKey: string;
3
+ }
4
+ export interface YouTubeVideo {
5
+ id: string;
6
+ title: string;
7
+ description: string;
8
+ channelId: string;
9
+ channelTitle: string;
10
+ publishedAt: string;
11
+ thumbnails: {
12
+ default?: {
13
+ url: string;
14
+ width: number;
15
+ height: number;
16
+ };
17
+ medium?: {
18
+ url: string;
19
+ width: number;
20
+ height: number;
21
+ };
22
+ high?: {
23
+ url: string;
24
+ width: number;
25
+ height: number;
26
+ };
27
+ };
28
+ duration?: string;
29
+ viewCount?: string;
30
+ likeCount?: string;
31
+ commentCount?: string;
32
+ }
33
+ export interface YouTubeChannel {
34
+ id: string;
35
+ title: string;
36
+ description: string;
37
+ customUrl?: string;
38
+ publishedAt: string;
39
+ thumbnails: {
40
+ default?: {
41
+ url: string;
42
+ };
43
+ medium?: {
44
+ url: string;
45
+ };
46
+ high?: {
47
+ url: string;
48
+ };
49
+ };
50
+ subscriberCount?: string;
51
+ videoCount?: string;
52
+ }
53
+ export declare class YouTubeMonitorClient {
54
+ private config;
55
+ private baseUrl;
56
+ constructor(config: YouTubeConfig);
57
+ static fromConfig(): YouTubeMonitorClient;
58
+ private makeRequest;
59
+ getChannelById(channelId: string): Promise<YouTubeChannel | null>;
60
+ getChannelByHandle(handle: string): Promise<YouTubeChannel | null>;
61
+ getLatestVideos(channelId: string, options?: {
62
+ maxResults?: number;
63
+ publishedAfter?: Date;
64
+ }): Promise<YouTubeVideo[]>;
65
+ getVideoTranscript(videoId: string): Promise<string | null>;
66
+ searchVideos(query: string, options?: {
67
+ maxResults?: number;
68
+ publishedAfter?: Date;
69
+ channelId?: string;
70
+ }): Promise<YouTubeVideo[]>;
71
+ }
72
+ export declare function isYouTubeConfigured(): boolean;
@@ -0,0 +1,180 @@
1
+ import { getConfig } from "../../commands/config.js";
2
+ function mapChannelItem(item) {
3
+ return {
4
+ id: item.id,
5
+ title: item.snippet.title,
6
+ description: item.snippet.description,
7
+ customUrl: item.snippet.customUrl,
8
+ publishedAt: item.snippet.publishedAt,
9
+ thumbnails: item.snippet.thumbnails,
10
+ subscriberCount: item.statistics?.subscriberCount,
11
+ videoCount: item.statistics?.videoCount,
12
+ };
13
+ }
14
+ export class YouTubeMonitorClient {
15
+ config;
16
+ baseUrl = 'https://www.googleapis.com/youtube/v3';
17
+ constructor(config) {
18
+ this.config = config;
19
+ }
20
+ static fromConfig() {
21
+ const config = getConfig();
22
+ const env = process.env.HUSKY_ENV || 'PROD';
23
+ const apiKey = process.env[`${env}_YOUTUBE_API_KEY`] || process.env.YOUTUBE_API_KEY || config.youtubeApiKey;
24
+ if (!apiKey) {
25
+ throw new Error("YouTube API Key not configured. Please set youtube-api-key.");
26
+ }
27
+ const clientConfig = {
28
+ apiKey,
29
+ };
30
+ return new YouTubeMonitorClient(clientConfig);
31
+ }
32
+ async makeRequest(endpoint, params, retryCount = 0) {
33
+ const url = new URL(`${this.baseUrl}${endpoint}`);
34
+ url.searchParams.append('key', this.config.apiKey);
35
+ Object.entries(params).forEach(([key, value]) => {
36
+ if (value !== undefined && value !== null)
37
+ url.searchParams.append(key, value);
38
+ });
39
+ const timeout = 15000;
40
+ const controller = new AbortController();
41
+ const id = setTimeout(() => controller.abort(), timeout);
42
+ try {
43
+ const response = await fetch(url.toString(), {
44
+ signal: controller.signal,
45
+ });
46
+ clearTimeout(id);
47
+ if (response.status === 429 && retryCount < 2) {
48
+ await new Promise(resolve => setTimeout(resolve, 5000 * (retryCount + 1)));
49
+ return this.makeRequest(endpoint, params, retryCount + 1);
50
+ }
51
+ if (!response.ok) {
52
+ const error = await response.text();
53
+ // Redact API key from error message
54
+ const redactedError = error.replace(this.config.apiKey, '[REDACTED]');
55
+ throw new Error(`YouTube API error: ${response.status} ${response.statusText} - ${redactedError}`);
56
+ }
57
+ return response.json();
58
+ }
59
+ catch (err) {
60
+ clearTimeout(id);
61
+ throw err;
62
+ }
63
+ }
64
+ async getChannelById(channelId) {
65
+ const data = await this.makeRequest('/channels', {
66
+ part: 'snippet,statistics',
67
+ id: channelId,
68
+ });
69
+ if (!data.items || data.items.length === 0) {
70
+ return null;
71
+ }
72
+ return mapChannelItem(data.items[0]);
73
+ }
74
+ async getChannelByHandle(handle) {
75
+ const cleanHandle = handle.startsWith('@') ? handle : `@${handle}`;
76
+ const data = await this.makeRequest('/channels', {
77
+ part: 'snippet,statistics',
78
+ forHandle: cleanHandle,
79
+ });
80
+ if (!data.items || data.items.length === 0) {
81
+ return null;
82
+ }
83
+ return mapChannelItem(data.items[0]);
84
+ }
85
+ async getLatestVideos(channelId, options = {}) {
86
+ const { maxResults = 10, publishedAfter } = options;
87
+ const clampedResults = Math.min(maxResults, 50);
88
+ const params = {
89
+ part: 'snippet',
90
+ channelId,
91
+ type: 'video',
92
+ order: 'date',
93
+ maxResults: clampedResults.toString(),
94
+ };
95
+ if (publishedAfter) {
96
+ params.publishedAfter = publishedAfter.toISOString();
97
+ }
98
+ const data = await this.makeRequest('/search', params);
99
+ if (!data.items || data.items.length === 0) {
100
+ return [];
101
+ }
102
+ const videoIds = data.items.map(item => item.id.videoId).filter(Boolean);
103
+ if (videoIds.length === 0) {
104
+ return [];
105
+ }
106
+ // Get video details for duration, view count, etc. (Max 50 IDs per call)
107
+ const detailsData = await this.makeRequest('/videos', {
108
+ part: 'contentDetails,statistics',
109
+ id: videoIds.slice(0, 50).join(','),
110
+ });
111
+ const detailsMap = new Map(detailsData.items?.map(item => [item.id, item]) || []);
112
+ return data.items
113
+ .filter(item => item.id.videoId)
114
+ .map(item => {
115
+ const videoId = item.id.videoId;
116
+ const details = detailsMap.get(videoId);
117
+ return {
118
+ id: videoId,
119
+ title: item.snippet.title,
120
+ description: item.snippet.description,
121
+ channelId: item.snippet.channelId,
122
+ channelTitle: item.snippet.channelTitle,
123
+ publishedAt: item.snippet.publishedAt,
124
+ thumbnails: item.snippet.thumbnails,
125
+ duration: details?.contentDetails?.duration,
126
+ viewCount: details?.statistics?.viewCount,
127
+ likeCount: details?.statistics?.likeCount,
128
+ commentCount: details?.statistics?.commentCount,
129
+ };
130
+ });
131
+ }
132
+ async getVideoTranscript(videoId) {
133
+ try {
134
+ const { YoutubeTranscript } = await import('youtube-transcript');
135
+ const transcript = await YoutubeTranscript.fetchTranscript(videoId);
136
+ return transcript.map(entry => entry.text).join(' ');
137
+ }
138
+ catch (error) {
139
+ return null;
140
+ }
141
+ }
142
+ async searchVideos(query, options = {}) {
143
+ const { maxResults = 10, publishedAfter, channelId } = options;
144
+ const clampedResults = Math.min(maxResults, 50);
145
+ const params = {
146
+ part: 'snippet',
147
+ q: query,
148
+ type: 'video',
149
+ order: 'date',
150
+ maxResults: clampedResults.toString(),
151
+ };
152
+ if (publishedAfter) {
153
+ params.publishedAfter = publishedAfter.toISOString();
154
+ }
155
+ if (channelId) {
156
+ params.channelId = channelId;
157
+ }
158
+ const data = await this.makeRequest('/search', params);
159
+ if (!data.items) {
160
+ return [];
161
+ }
162
+ return data.items
163
+ .filter(item => item.id.videoId)
164
+ .map(item => ({
165
+ id: item.id.videoId,
166
+ title: item.snippet.title,
167
+ description: item.snippet.description,
168
+ channelId: item.snippet.channelId,
169
+ channelTitle: item.snippet.channelTitle,
170
+ publishedAt: item.snippet.publishedAt,
171
+ thumbnails: item.snippet.thumbnails,
172
+ }));
173
+ }
174
+ }
175
+ export function isYouTubeConfigured() {
176
+ const config = getConfig();
177
+ const env = process.env.HUSKY_ENV || 'PROD';
178
+ const apiKey = process.env[`${env}_YOUTUBE_API_KEY`] || process.env.YOUTUBE_API_KEY || config.youtubeApiKey;
179
+ return !!apiKey;
180
+ }
@@ -1,26 +1,21 @@
1
- import { getConfig, isSessionActive } from "../commands/config.js";
1
+ import { getConfig, getAuthHeaders } from "../commands/config.js";
2
2
  const CACHE_TTL_MS = 5 * 60 * 1000;
3
3
  let cache = null;
4
4
  let fetchPromise = null;
5
5
  async function fetchPermissions() {
6
6
  const config = getConfig();
7
- if (!config.apiUrl || !config.apiKey) {
7
+ if (!config.apiUrl) {
8
8
  throw new Error("API not configured");
9
9
  }
10
- // If there's an active session, use the session's role
11
- // This ensures permissions match the logged-in agent, not the API key
12
- const roleToFetch = isSessionActive() && config.sessionRole
13
- ? config.sessionRole
14
- : undefined;
15
- // Build URL - if we have a session role, request permissions for that specific role
16
- const url = new URL("/api/auth/whoami", config.apiUrl);
17
- if (roleToFetch) {
18
- url.searchParams.set("role", roleToFetch);
10
+ const authHeaders = getAuthHeaders();
11
+ if (!authHeaders.Authorization) {
12
+ throw new Error("No active session. Run: husky auth login --agent <name>");
19
13
  }
14
+ const url = new URL("/api/auth/whoami", config.apiUrl);
20
15
  const res = await fetch(url.toString(), {
21
16
  method: "GET",
22
17
  headers: {
23
- "x-api-key": config.apiKey,
18
+ ...authHeaders,
24
19
  "Content-Type": "application/json",
25
20
  },
26
21
  });
@@ -29,12 +24,8 @@ async function fetchPermissions() {
29
24
  throw new Error(error.message || error.error || `HTTP ${res.status}`);
30
25
  }
31
26
  const data = await res.json();
32
- // If we have a session role, override the returned role with the session role
33
- // and fetch permissions for that role
34
- const effectiveRole = roleToFetch || data.role;
35
- const effectivePermissions = roleToFetch
36
- ? await fetchPermissionsForRole(config.apiUrl, config.apiKey, roleToFetch)
37
- : data.permissions;
27
+ const effectiveRole = data.role || config.sessionRole || "unknown";
28
+ const effectivePermissions = data.permissions || [];
38
29
  const kbPermissions = effectivePermissions
39
30
  .filter((p) => p.startsWith("kb:"))
40
31
  .map((p) => p.replace("kb:", ""));
@@ -45,23 +36,6 @@ async function fetchPermissions() {
45
36
  fetchedAt: Date.now(),
46
37
  };
47
38
  }
48
- async function fetchPermissionsForRole(apiUrl, apiKey, role) {
49
- // Use the /permissions/:role endpoint to get permissions for a specific role
50
- const url = new URL(`/api/auth/permissions/${encodeURIComponent(role)}`, apiUrl);
51
- const res = await fetch(url.toString(), {
52
- method: "GET",
53
- headers: {
54
- "x-api-key": apiKey,
55
- "Content-Type": "application/json",
56
- },
57
- });
58
- if (!res.ok) {
59
- // Fall back to empty permissions if fetch fails
60
- return [];
61
- }
62
- const data = await res.json();
63
- return data.permissions || [];
64
- }
65
39
  export async function getPermissions() {
66
40
  const now = Date.now();
67
41
  if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonfestl/husky-cli",
3
- "version": "1.38.4",
3
+ "version": "1.38.6",
4
4
  "description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
5
5
  "type": "module",
6
6
  "bin": {