@hidden-leaf/x-skill 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.
Files changed (53) hide show
  1. package/.env.example +50 -0
  2. package/CLAUDE.snippet.md +34 -0
  3. package/README.md +220 -0
  4. package/SKILL.md +265 -0
  5. package/dist/cache/store.d.ts +48 -0
  6. package/dist/cache/store.d.ts.map +1 -0
  7. package/dist/cache/store.js +381 -0
  8. package/dist/cache/store.js.map +1 -0
  9. package/dist/clients/types.d.ts +217 -0
  10. package/dist/clients/types.d.ts.map +1 -0
  11. package/dist/clients/types.js +77 -0
  12. package/dist/clients/types.js.map +1 -0
  13. package/dist/clients/x-client.d.ts +111 -0
  14. package/dist/clients/x-client.d.ts.map +1 -0
  15. package/dist/clients/x-client.js +421 -0
  16. package/dist/clients/x-client.js.map +1 -0
  17. package/dist/index.d.ts +20 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +52 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/skills/bookmarks/index.d.ts +79 -0
  22. package/dist/skills/bookmarks/index.d.ts.map +1 -0
  23. package/dist/skills/bookmarks/index.js +288 -0
  24. package/dist/skills/bookmarks/index.js.map +1 -0
  25. package/dist/skills/bookmarks/synthesize.d.ts +40 -0
  26. package/dist/skills/bookmarks/synthesize.d.ts.map +1 -0
  27. package/dist/skills/bookmarks/synthesize.js +164 -0
  28. package/dist/skills/bookmarks/synthesize.js.map +1 -0
  29. package/dist/skills/bookmarks/types.d.ts +71 -0
  30. package/dist/skills/bookmarks/types.d.ts.map +1 -0
  31. package/dist/skills/bookmarks/types.js +6 -0
  32. package/dist/skills/bookmarks/types.js.map +1 -0
  33. package/dist/utils/logger.d.ts +10 -0
  34. package/dist/utils/logger.d.ts.map +1 -0
  35. package/dist/utils/logger.js +43 -0
  36. package/dist/utils/logger.js.map +1 -0
  37. package/package.json +68 -0
  38. package/scripts/cache-report.ts +25 -0
  39. package/scripts/create-atlsk-ticket.ts +68 -0
  40. package/scripts/create-deep-dive-ticket.ts +66 -0
  41. package/scripts/create-jira-project.ts +31 -0
  42. package/scripts/create-launch-and-roadmap.ts +200 -0
  43. package/scripts/create-next-session.ts +71 -0
  44. package/scripts/create-roadmap.ts +150 -0
  45. package/scripts/debug-api.ts +45 -0
  46. package/scripts/debug-folder.ts +37 -0
  47. package/scripts/jira-close-v1-tickets.ts +83 -0
  48. package/scripts/jira-v1-close-and-post-v1.ts +272 -0
  49. package/scripts/oauth-flow.ts +216 -0
  50. package/scripts/postinstall.js +112 -0
  51. package/scripts/sync-test.ts +36 -0
  52. package/scripts/test-refresh-forced.ts +29 -0
  53. package/scripts/test-refresh.ts +25 -0
@@ -0,0 +1,216 @@
1
+ /**
2
+ * OAuth 2.0 Authorization Code Flow with PKCE for X API.
3
+ *
4
+ * Run once to get your User Access Token:
5
+ * npx tsx scripts/oauth-flow.ts
6
+ *
7
+ * This will:
8
+ * 1. Generate a PKCE code challenge
9
+ * 2. Open your browser to X's authorize page
10
+ * 3. Start a local server to catch the callback
11
+ * 4. Exchange the auth code for a User Access Token
12
+ * 5. Print the token + user ID to paste into your .env
13
+ */
14
+
15
+ import * as http from 'http';
16
+ import * as crypto from 'crypto';
17
+ import * as url from 'url';
18
+ import * as dotenv from 'dotenv';
19
+
20
+ dotenv.config();
21
+
22
+ const CLIENT_ID = process.env.X_CONSUMER_KEY;
23
+ const CLIENT_SECRET = process.env.X_CONSUMER_SECRET;
24
+ const CALLBACK_URL = process.env.X_CALLBACK_URL || 'http://localhost:3000/callback';
25
+ const SCOPES = ['bookmark.read', 'tweet.read', 'users.read', 'offline.access'];
26
+
27
+ if (!CLIENT_ID || !CLIENT_SECRET) {
28
+ console.error('ERROR: X_CONSUMER_KEY and X_CONSUMER_SECRET must be set in .env');
29
+ process.exit(1);
30
+ }
31
+
32
+ // =============================================================================
33
+ // PKCE helpers
34
+ // =============================================================================
35
+
36
+ function generateCodeVerifier(): string {
37
+ return crypto.randomBytes(32).toString('base64url');
38
+ }
39
+
40
+ function generateCodeChallenge(verifier: string): string {
41
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
42
+ }
43
+
44
+ function generateState(): string {
45
+ return crypto.randomBytes(16).toString('hex');
46
+ }
47
+
48
+ // =============================================================================
49
+ // Token exchange
50
+ // =============================================================================
51
+
52
+ async function exchangeCodeForToken(code: string, codeVerifier: string): Promise<{
53
+ access_token: string;
54
+ refresh_token?: string;
55
+ expires_in: number;
56
+ token_type: string;
57
+ scope: string;
58
+ }> {
59
+ const params = new URLSearchParams({
60
+ grant_type: 'authorization_code',
61
+ code,
62
+ redirect_uri: CALLBACK_URL,
63
+ code_verifier: codeVerifier,
64
+ client_id: CLIENT_ID!,
65
+ });
66
+
67
+ const basicAuth = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64');
68
+
69
+ const response = await fetch('https://api.x.com/2/oauth2/token', {
70
+ method: 'POST',
71
+ headers: {
72
+ 'Content-Type': 'application/x-www-form-urlencoded',
73
+ Authorization: `Basic ${basicAuth}`,
74
+ },
75
+ body: params.toString(),
76
+ });
77
+
78
+ if (!response.ok) {
79
+ const text = await response.text();
80
+ throw new Error(`Token exchange failed (${response.status}): ${text}`);
81
+ }
82
+
83
+ return response.json();
84
+ }
85
+
86
+ async function fetchUserId(accessToken: string): Promise<{ id: string; username: string; name: string }> {
87
+ const response = await fetch('https://api.x.com/2/users/me', {
88
+ headers: { Authorization: `Bearer ${accessToken}` },
89
+ });
90
+
91
+ if (!response.ok) {
92
+ const text = await response.text();
93
+ throw new Error(`Failed to fetch user info (${response.status}): ${text}`);
94
+ }
95
+
96
+ const data = await response.json() as { data: { id: string; username: string; name: string } };
97
+ return data.data;
98
+ }
99
+
100
+ // =============================================================================
101
+ // Main flow
102
+ // =============================================================================
103
+
104
+ async function main(): Promise<void> {
105
+ const codeVerifier = generateCodeVerifier();
106
+ const codeChallenge = generateCodeChallenge(codeVerifier);
107
+ const state = generateState();
108
+
109
+ // Build authorization URL
110
+ const authUrl = new URL('https://x.com/i/oauth2/authorize');
111
+ authUrl.searchParams.set('response_type', 'code');
112
+ authUrl.searchParams.set('client_id', CLIENT_ID!);
113
+ authUrl.searchParams.set('redirect_uri', CALLBACK_URL);
114
+ authUrl.searchParams.set('scope', SCOPES.join(' '));
115
+ authUrl.searchParams.set('state', state);
116
+ authUrl.searchParams.set('code_challenge', codeChallenge);
117
+ authUrl.searchParams.set('code_challenge_method', 'S256');
118
+
119
+ console.log('\n=== X API OAuth 2.0 Flow ===\n');
120
+ console.log('Open this URL in your browser:\n');
121
+ console.log(authUrl.toString());
122
+ console.log('\nWaiting for callback on http://localhost:3000...\n');
123
+
124
+ // Try to open browser automatically
125
+ try {
126
+ const { exec } = await import('child_process');
127
+ const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
128
+ exec(`${openCmd} "${authUrl.toString()}"`);
129
+ } catch {
130
+ // Manual open is fine
131
+ }
132
+
133
+ // Start local server to catch callback
134
+ const code = await new Promise<string>((resolve, reject) => {
135
+ const server = http.createServer((req, res) => {
136
+ if (!req.url?.startsWith('/callback')) {
137
+ res.writeHead(404);
138
+ res.end('Not found');
139
+ return;
140
+ }
141
+
142
+ const parsed = url.parse(req.url, true);
143
+ const callbackState = parsed.query.state as string;
144
+ const callbackCode = parsed.query.code as string;
145
+ const error = parsed.query.error as string;
146
+
147
+ if (error) {
148
+ res.writeHead(400);
149
+ res.end(`Authorization error: ${error}`);
150
+ server.close();
151
+ reject(new Error(`Authorization denied: ${error}`));
152
+ return;
153
+ }
154
+
155
+ if (callbackState !== state) {
156
+ res.writeHead(400);
157
+ res.end('State mismatch — possible CSRF. Try again.');
158
+ server.close();
159
+ reject(new Error('State mismatch'));
160
+ return;
161
+ }
162
+
163
+ res.writeHead(200, { 'Content-Type': 'text/html' });
164
+ res.end(`
165
+ <html>
166
+ <body style="background: #15202B; color: #E7E9EA; font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
167
+ <div style="text-align: center;">
168
+ <h1>Authorized!</h1>
169
+ <p>You can close this tab and return to the terminal.</p>
170
+ </div>
171
+ </body>
172
+ </html>
173
+ `);
174
+ server.close();
175
+ resolve(callbackCode);
176
+ });
177
+
178
+ server.listen(3000, () => {
179
+ // Server is ready
180
+ });
181
+
182
+ // Timeout after 5 minutes
183
+ setTimeout(() => {
184
+ server.close();
185
+ reject(new Error('Timeout — no callback received within 5 minutes'));
186
+ }, 300_000);
187
+ });
188
+
189
+ console.log('Authorization code received. Exchanging for token...\n');
190
+
191
+ // Exchange code for token
192
+ const token = await exchangeCodeForToken(code, codeVerifier);
193
+ console.log('Token received! Fetching user info...\n');
194
+
195
+ // Fetch user ID
196
+ const user = await fetchUserId(token.access_token);
197
+
198
+ console.log('=== SUCCESS ===\n');
199
+ console.log(`Authenticated as: ${user.name} (@${user.username})`);
200
+ console.log(`User ID: ${user.id}`);
201
+ console.log(`Token expires in: ${token.expires_in}s (~${Math.round(token.expires_in / 3600)}h)`);
202
+ console.log(`Scopes: ${token.scope}\n`);
203
+
204
+ console.log('Add these to your .env:\n');
205
+ console.log(`X_USER_ACCESS_TOKEN=${token.access_token}`);
206
+ console.log(`X_USER_ID=${user.id}`);
207
+ if (token.refresh_token) {
208
+ console.log(`X_REFRESH_TOKEN=${token.refresh_token}`);
209
+ }
210
+ console.log('\nDone! You can now run sync.\n');
211
+ }
212
+
213
+ main().catch((err) => {
214
+ console.error('OAuth flow failed:', err.message);
215
+ process.exit(1);
216
+ });
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * postinstall hook — auto-injects x-skill context into the
4
+ * consuming project's CLAUDE.md so Claude Code picks it up immediately.
5
+ *
6
+ * Safe to re-run: skips if the section already exists.
7
+ * Only runs when installed as a dependency (not during local dev).
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const MARKER = '<!-- @hidden-leaf/x-skill -->';
14
+
15
+ const SNIPPET = `
16
+ ${MARKER}
17
+ ## X Bookmark Intelligence (Library-Based Skill)
18
+
19
+ This project uses \`@hidden-leaf/x-skill\` — a TypeScript library for X (Twitter) bookmark intelligence.
20
+ This is NOT an MCP tool or registered skill. To use it, write a TypeScript script and execute it with \`npx tsx <script>.ts\`.
21
+
22
+ **Setup:** Credentials in \`.env\` — needs \`X_BEARER_TOKEN\`, \`X_USER_ID\`.
23
+ See node_modules/@hidden-leaf/x-skill/.env.example for all options.
24
+
25
+ **Skill reference:** Read node_modules/@hidden-leaf/x-skill/SKILL.md for the full API before using.
26
+
27
+ **How to use:** Write a .ts script, then run it:
28
+ \`\`\`typescript
29
+ import { createBookmarksSkillFromEnv } from '@hidden-leaf/x-skill';
30
+ const skill = createBookmarksSkillFromEnv();
31
+
32
+ // List folders: await skill.listFolders()
33
+ // Fetch posts: await skill.fetchByFolderName('Robotics')
34
+ // Research brief: await skill.brief({ folderName: 'AI MEDICINE', hlnContext: ['Applied AI Studio'] })
35
+ \`\`\`
36
+ Then execute: \`npx tsx <script>.ts\`
37
+ <!-- /@hidden-leaf/x-skill -->
38
+ `.trimStart();
39
+
40
+ function findProjectRoot() {
41
+ // Walk up from node_modules/@hidden-leaf/x-skill/scripts/
42
+ let dir = __dirname;
43
+ for (let i = 0; i < 10; i++) {
44
+ dir = path.dirname(dir);
45
+ const pkg = path.join(dir, 'package.json');
46
+ if (fs.existsSync(pkg)) {
47
+ try {
48
+ const json = JSON.parse(fs.readFileSync(pkg, 'utf8'));
49
+ // Don't modify our own repo
50
+ if (json.name === '@hidden-leaf/x-skill') continue;
51
+ return dir;
52
+ } catch {
53
+ continue;
54
+ }
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function run() {
61
+ // Skip during local development (npm install in the skill repo itself)
62
+ const ownPkg = path.resolve(__dirname, '..', 'package.json');
63
+ if (fs.existsSync(ownPkg)) {
64
+ try {
65
+ const json = JSON.parse(fs.readFileSync(ownPkg, 'utf8'));
66
+ if (json.name === '@hidden-leaf/x-skill') {
67
+ // Check if we're in node_modules (installed as dep) or at repo root (local dev)
68
+ if (!__dirname.includes('node_modules')) return;
69
+ }
70
+ } catch {
71
+ // continue
72
+ }
73
+ }
74
+
75
+ const root = findProjectRoot();
76
+ if (!root) return;
77
+
78
+ const claudeMd = path.join(root, 'CLAUDE.md');
79
+
80
+ if (fs.existsSync(claudeMd)) {
81
+ const existing = fs.readFileSync(claudeMd, 'utf8');
82
+ if (existing.includes(MARKER)) {
83
+ // Already injected — skip
84
+ return;
85
+ }
86
+ // Append to existing CLAUDE.md
87
+ fs.writeFileSync(claudeMd, existing.trimEnd() + '\n\n' + SNIPPET);
88
+ console.log('[@hidden-leaf/x-skill] Added X bookmark intelligence section to CLAUDE.md');
89
+ console.log('[@hidden-leaf/x-skill] Restart Claude Code to activate the skill (CLAUDE.md is loaded at session start)');
90
+ } else {
91
+ // Create new CLAUDE.md
92
+ fs.writeFileSync(claudeMd, SNIPPET);
93
+ console.log('[@hidden-leaf/x-skill] Created CLAUDE.md with X bookmark intelligence');
94
+ console.log('[@hidden-leaf/x-skill] Restart Claude Code to activate the skill (CLAUDE.md is loaded at session start)');
95
+ }
96
+
97
+ // Copy .env.example if no .env exists
98
+ const envFile = path.join(root, '.env');
99
+ if (!fs.existsSync(envFile)) {
100
+ const envExample = path.resolve(__dirname, '..', '.env.example');
101
+ if (fs.existsSync(envExample)) {
102
+ fs.copyFileSync(envExample, envFile);
103
+ console.log('[@hidden-leaf/x-skill] Created .env from template — fill in your X API credentials');
104
+ }
105
+ }
106
+ }
107
+
108
+ try {
109
+ run();
110
+ } catch {
111
+ // postinstall should never break npm install
112
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * First sync test — pulls all bookmark folders + tweets into local cache.
3
+ */
4
+ import { createBookmarksSkillFromEnv } from '../src/skills/bookmarks/index.js';
5
+
6
+ async function main() {
7
+ const skill = createBookmarksSkillFromEnv();
8
+
9
+ console.log('Syncing all bookmark folders from X API...\n');
10
+
11
+ const result = await skill.syncAll();
12
+
13
+ console.log(`Synced at: ${result.syncedAt}`);
14
+ console.log(`Folders: ${result.totalFolders}`);
15
+ console.log(`Total tweets: ${result.totalTweets}\n`);
16
+
17
+ for (const folder of result.folders) {
18
+ console.log(` ${folder.name} — ${folder.tweetCount} tweets`);
19
+ }
20
+
21
+ console.log('\nCache stats:');
22
+ const stats = skill.stats();
23
+ console.log(` Folders: ${stats.folders}`);
24
+ console.log(` Tweets: ${stats.tweets}`);
25
+ console.log(` Users: ${stats.users}`);
26
+ console.log(` Last sync: ${stats.lastSync}`);
27
+
28
+ skill.close();
29
+ console.log('\nDone!');
30
+ }
31
+
32
+ main().catch((err) => {
33
+ console.error('Sync failed:', err.message);
34
+ if (err.status) console.error(`Status: ${err.status}`);
35
+ process.exit(1);
36
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Force-test auto-refresh by using an invalid access token.
3
+ * The client should detect 401, refresh using the refresh token, and succeed.
4
+ */
5
+ import * as dotenv from 'dotenv';
6
+ dotenv.config();
7
+ import { XClient } from '../src/clients/x-client.js';
8
+
9
+ async function main() {
10
+ console.log('Force-testing auto-refresh with expired/invalid token...\n');
11
+
12
+ const client = new XClient({
13
+ userAccessToken: 'deliberately_invalid_token_to_trigger_401',
14
+ userId: process.env.X_USER_ID!,
15
+ refreshToken: process.env.X_REFRESH_TOKEN,
16
+ consumerKey: process.env.X_CONSUMER_KEY,
17
+ consumerSecret: process.env.X_CONSUMER_SECRET,
18
+ });
19
+
20
+ const me = await client.getMe();
21
+
22
+ console.log(`\nAuthenticated as: ${me.data.name} (@${me.data.username})`);
23
+ console.log('Auto-refresh worked — recovered from expired token!');
24
+ }
25
+
26
+ main().catch((err) => {
27
+ console.error('Failed:', err.message);
28
+ process.exit(1);
29
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Test auto-refresh: calls /users/me.
3
+ * If token is expired, it should auto-refresh and succeed.
4
+ */
5
+ import * as dotenv from 'dotenv';
6
+ dotenv.config();
7
+ import { createXClientFromEnv } from '../src/clients/x-client.js';
8
+
9
+ async function main() {
10
+ console.log('Testing auto-refresh...');
11
+ console.log(`Refresh token present: ${!!process.env.X_REFRESH_TOKEN}`);
12
+ console.log(`Consumer key present: ${!!process.env.X_CONSUMER_KEY}`);
13
+ console.log(`Consumer secret present: ${!!process.env.X_CONSUMER_SECRET}\n`);
14
+
15
+ const client = createXClientFromEnv();
16
+ const me = await client.getMe();
17
+
18
+ console.log(`Authenticated as: ${me.data.name} (@${me.data.username})`);
19
+ console.log('Auto-refresh is working!');
20
+ }
21
+
22
+ main().catch((err) => {
23
+ console.error('Failed:', err.message);
24
+ process.exit(1);
25
+ });