noslop 0.1.0 → 0.2.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/README.md CHANGED
@@ -74,12 +74,20 @@ noslop
74
74
  |---------|-------------|
75
75
  | `noslop ready <id>` | Mark draft as ready |
76
76
  | `noslop unready <id>` | Mark as in-progress |
77
- | `noslop post <id>` | Move to posts folder |
78
- | `noslop unpost <id>` | Move back to drafts |
79
- | `noslop publish <id> <url>` | Add published URL |
80
77
  | `noslop schedule <id> <datetime>` | Set schedule (YYYY-MM-DD HH:MM) |
81
78
  | `noslop delete <id>` | Delete a draft |
82
79
 
80
+ ### X Integration
81
+
82
+ | Command | Description |
83
+ |---------|-------------|
84
+ | `noslop auth x` | Authenticate with X API |
85
+ | `noslop auth x --status` | Check authentication status |
86
+ | `noslop auth x --list` | List all authenticated accounts |
87
+ | `noslop auth x --unlink` | Remove account link from project |
88
+ | `noslop auth x --logout <name>` | Remove stored credentials |
89
+ | `noslop x post <id>` | Post a draft to X immediately |
90
+
83
91
  ## TUI Keyboard Shortcuts
84
92
 
85
93
  | Key | Action |
@@ -87,7 +95,7 @@ noslop
87
95
  | `Tab` | Switch between Drafts/Posts |
88
96
  | `↑/↓` | Navigate items |
89
97
  | `Enter` | Toggle ready (drafts) / Add URL (posts) |
90
- | `Space` | Move to Posts / Move to Drafts |
98
+ | `Space` | Move to Posts |
91
99
  | `Backspace` | Delete draft (in-progress only) |
92
100
  | `s` | Toggle schedule view |
93
101
  | `←/→` | Navigate weeks (schedule view) |
@@ -138,6 +146,78 @@ Description of media to attach
138
146
  https://x.com/user/status/123
139
147
  ```
140
148
 
149
+ The `## Published` URL is automatically added when you use `noslop x post`.
150
+
151
+ ## X Integration
152
+
153
+ noslop can post directly to X (Twitter).
154
+
155
+ ### Setup
156
+
157
+ 1. Create an X Developer App at [X Developer Portal](https://developer.x.com/en/portal/dashboard)
158
+ - Enable "Read and Write" permissions
159
+ - Note your API Key and API Secret
160
+
161
+ 2. Authenticate (in your noslop project folder):
162
+
163
+ ```bash
164
+ noslop auth x
165
+ ```
166
+
167
+ 3. Follow the prompts:
168
+ - Enter an account name (e.g., "personal", "work", "client-acme")
169
+ - Enter your API Key and Secret (or set `NOSLOP_X_API_KEY` and `NOSLOP_X_API_SECRET`)
170
+ - Browser opens to X for authorization
171
+ - Click "Authorize" on X
172
+ - Enter the PIN shown by X
173
+
174
+ The account name is saved in your project's `CLAUDE.md` file:
175
+ ```markdown
176
+ ## X Account
177
+ personal
178
+ ```
179
+
180
+ Credentials are stored globally at `~/.config/noslop/credentials/x/`.
181
+
182
+ ### Multiple Accounts
183
+
184
+ Each noslop project can be linked to a different X account:
185
+
186
+ ```bash
187
+ # In project A (your personal account)
188
+ noslop auth x # Enter: personal
189
+
190
+ # In project B (client work)
191
+ noslop auth x # Enter: client-acme
192
+
193
+ # List all authenticated accounts
194
+ noslop auth x --list
195
+
196
+ # Check which account this project uses
197
+ noslop auth x --status
198
+
199
+ # Unlink project from account
200
+ noslop auth x --unlink
201
+
202
+ # Remove stored credentials
203
+ noslop auth x --logout personal
204
+ ```
205
+
206
+ ### Workflow
207
+
208
+ ```bash
209
+ # 1. Create a draft
210
+ noslop new "Monday Motivation"
211
+
212
+ # 2. Mark it ready when done
213
+ noslop ready D001
214
+
215
+ # 3. Post to X
216
+ noslop x post D001
217
+
218
+ # Draft is automatically moved to posts/ with the X URL saved
219
+ ```
220
+
141
221
  ## Working with AI Assistants
142
222
 
143
223
  noslop is designed to work with Claude Code and other AI assistants:
package/dist/index.js CHANGED
@@ -1,7 +1,46 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { isNoslopProject, getAllContent, findItem, createDraft, updateStatus, updateSchedule, moveToPosts, moveToDrafts, addPublishedUrl, deleteDraft, sortBySchedule, } from './lib/content.js';
3
+ import { isNoslopProject, getAllContent, findItem, createDraft, updateStatus, updateSchedule, moveToPosts, addPublishedUrl, deleteDraft, sortBySchedule, } from './lib/content.js';
4
4
  import { initProject, ensureNoslopMd } from './lib/templates.js';
5
+ import { hasXCredentials, loadXCredentials, saveXCredentials, deleteXCredentials, validateXCredentials, getProjectXAccount, setProjectXAccount, removeProjectXAccount, listXAccounts, isValidAccountName, } from './lib/config.js';
6
+ import { postTweet, verifyCredentials, getOAuthRequestToken, getOAuthAccessToken, } from './lib/x-api.js';
7
+ import readline from 'readline';
8
+ import { exec } from 'child_process';
9
+ /**
10
+ * Prompt user for input
11
+ */
12
+ function prompt(question) {
13
+ const rl = readline.createInterface({
14
+ input: process.stdin,
15
+ output: process.stdout,
16
+ });
17
+ return new Promise(resolve => {
18
+ rl.question(question, answer => {
19
+ rl.close();
20
+ resolve(answer.trim());
21
+ });
22
+ });
23
+ }
24
+ /**
25
+ * Open URL in default browser
26
+ */
27
+ function openBrowser(url) {
28
+ let command;
29
+ if (process.platform === 'darwin') {
30
+ command = `open "${url}"`;
31
+ }
32
+ else if (process.platform === 'win32') {
33
+ command = `start "${url}"`;
34
+ }
35
+ else {
36
+ command = `xdg-open "${url}"`;
37
+ }
38
+ exec(command, err => {
39
+ if (err) {
40
+ console.log(`Could not open browser. Please visit: ${url}`);
41
+ }
42
+ });
43
+ }
5
44
  const program = new Command();
6
45
  program
7
46
  .name('noslop')
@@ -185,12 +224,13 @@ program
185
224
  updateStatus(item, 'draft');
186
225
  console.log(`Marked as in-progress: ${item.folder}`);
187
226
  });
188
- // Post command (move to posts)
227
+ // Schedule command
189
228
  program
190
- .command('post')
191
- .description('Move draft to posts folder')
229
+ .command('schedule')
230
+ .description('Set/update scheduled time')
192
231
  .argument('<id>', 'Folder name or ID')
193
- .action((id) => {
232
+ .argument('<datetime>', 'Date and time (YYYY-MM-DD HH:MM)')
233
+ .action((id, datetime) => {
194
234
  if (!isNoslopProject()) {
195
235
  console.log('Not a noslop project. Run `noslop init` first.');
196
236
  process.exit(1);
@@ -200,13 +240,13 @@ program
200
240
  console.log(`Not found: ${id}`);
201
241
  process.exit(1);
202
242
  }
203
- moveToPosts(item);
204
- console.log(`Moved to posts: ${item.folder}`);
243
+ updateSchedule(item, datetime);
244
+ console.log(`Scheduled: ${item.folder} @ ${datetime}`);
205
245
  });
206
- // Unpost command (move back to drafts)
246
+ // Delete command
207
247
  program
208
- .command('unpost')
209
- .description('Move post back to drafts')
248
+ .command('delete')
249
+ .description('Delete a draft')
210
250
  .argument('<id>', 'Folder name or ID')
211
251
  .action((id) => {
212
252
  if (!isNoslopProject()) {
@@ -218,64 +258,297 @@ program
218
258
  console.log(`Not found: ${id}`);
219
259
  process.exit(1);
220
260
  }
221
- moveToDrafts(item);
222
- console.log(`Moved to drafts: ${item.folder}`);
261
+ deleteDraft(item);
262
+ console.log(`Deleted: ${item.folder}`);
223
263
  });
224
- // Publish command
225
- program
226
- .command('publish')
227
- .description('Add published URL to post')
228
- .argument('<id>', 'Folder name or ID')
229
- .argument('<url>', 'Published URL')
230
- .action((id, url) => {
264
+ // Auth command group
265
+ const auth = program.command('auth').description('Manage platform authentication');
266
+ auth
267
+ .command('x')
268
+ .description('Authenticate with X (Twitter)')
269
+ .option('--status', 'Check authentication status')
270
+ .option('--list', 'List all authenticated accounts')
271
+ .option('--unlink', 'Remove account link from this project')
272
+ .option('--logout <account>', 'Remove stored credentials for account')
273
+ .action(async (options) => {
274
+ // --logout: Remove credentials for a specific account
275
+ if (options.logout) {
276
+ if (!hasXCredentials(options.logout)) {
277
+ console.log(`Account "${options.logout}" not found.`);
278
+ process.exit(1);
279
+ }
280
+ deleteXCredentials(options.logout);
281
+ console.log(`Credentials removed for account "${options.logout}".`);
282
+ return;
283
+ }
284
+ // --list: Show all authenticated accounts
285
+ if (options.list) {
286
+ const accounts = listXAccounts();
287
+ if (accounts.length === 0) {
288
+ console.log('No authenticated X accounts.');
289
+ console.log('Run `noslop auth x` to authenticate.');
290
+ return;
291
+ }
292
+ console.log('Authenticated X accounts:');
293
+ for (const acc of accounts) {
294
+ const handle = acc.screenName ? ` (@${acc.screenName})` : '';
295
+ console.log(` ${acc.name}${handle}`);
296
+ }
297
+ return;
298
+ }
299
+ // --unlink: Remove account link from project
300
+ if (options.unlink) {
301
+ if (!isNoslopProject()) {
302
+ console.log('Not a noslop project. Run `noslop init` first.');
303
+ process.exit(1);
304
+ }
305
+ const current = getProjectXAccount();
306
+ if (!current) {
307
+ console.log('This project is not linked to an X account.');
308
+ return;
309
+ }
310
+ removeProjectXAccount();
311
+ console.log(`Unlinked project from account "${current}".`);
312
+ return;
313
+ }
314
+ // --status: Show current account status
315
+ if (options.status) {
316
+ if (!isNoslopProject()) {
317
+ console.log('Not a noslop project. Run `noslop init` first.');
318
+ process.exit(1);
319
+ }
320
+ const accountName = getProjectXAccount();
321
+ if (!accountName) {
322
+ console.log('This project is not linked to an X account.');
323
+ console.log('Run `noslop auth x` to link an account.');
324
+ return;
325
+ }
326
+ if (!hasXCredentials(accountName)) {
327
+ console.log(`Project linked to "${accountName}" but credentials not found.`);
328
+ console.log('Run `noslop auth x` to re-authenticate.');
329
+ return;
330
+ }
331
+ const creds = loadXCredentials(accountName);
332
+ if (!creds || !validateXCredentials(creds)) {
333
+ console.log(`Credentials for "${accountName}" are invalid.`);
334
+ console.log('Run `noslop auth x` to re-authenticate.');
335
+ return;
336
+ }
337
+ console.log(`Account: ${accountName}`);
338
+ console.log(` Handle: @${creds.screenName || '(unknown)'}`);
339
+ console.log(` Since: ${creds.createdAt}`);
340
+ // Verify credentials work
341
+ const user = await verifyCredentials(creds);
342
+ console.log(` Status: ${user ? 'connected' : 'not connected'}`);
343
+ return;
344
+ }
345
+ // Default: Link project to account (with authentication if needed)
231
346
  if (!isNoslopProject()) {
232
347
  console.log('Not a noslop project. Run `noslop init` first.');
233
348
  process.exit(1);
234
349
  }
235
- const item = findItem(id);
236
- if (!item) {
237
- console.log(`Not found: ${id}`);
350
+ console.log('X API Authentication');
351
+ console.log('====================');
352
+ console.log('');
353
+ // Check if project already has an account
354
+ const existingAccount = getProjectXAccount();
355
+ if (existingAccount) {
356
+ console.log(`This project is linked to account "${existingAccount}".`);
357
+ const change = await prompt('Change account? [y/N]: ');
358
+ if (change.toLowerCase() !== 'y') {
359
+ return;
360
+ }
361
+ console.log('');
362
+ }
363
+ // Ask for account name
364
+ const accountName = await prompt('Account name for this project (e.g., personal, work): ');
365
+ if (!accountName) {
366
+ console.log('Account name is required.');
238
367
  process.exit(1);
239
368
  }
240
- addPublishedUrl(item, url);
241
- console.log(`Published: ${item.folder}`);
242
- console.log(` URL: ${url}`);
243
- });
244
- // Schedule command
245
- program
246
- .command('schedule')
247
- .description('Set/update scheduled time')
248
- .argument('<id>', 'Folder name or ID')
249
- .argument('<datetime>', 'Date and time (YYYY-MM-DD HH:MM)')
250
- .action((id, datetime) => {
251
- if (!isNoslopProject()) {
252
- console.log('Not a noslop project. Run `noslop init` first.');
369
+ if (!isValidAccountName(accountName)) {
370
+ console.log('Invalid account name. Use only letters, numbers, dashes, underscores.');
253
371
  process.exit(1);
254
372
  }
255
- const item = findItem(id);
256
- if (!item) {
257
- console.log(`Not found: ${id}`);
373
+ // Check if account already exists
374
+ if (hasXCredentials(accountName)) {
375
+ const creds = loadXCredentials(accountName);
376
+ const handle = creds?.screenName ? ` (@${creds.screenName})` : '';
377
+ console.log('');
378
+ console.log(`Account "${accountName}"${handle} already exists.`);
379
+ const useExisting = await prompt('Use this account? [Y/n]: ');
380
+ if (useExisting.toLowerCase() !== 'n') {
381
+ setProjectXAccount(accountName);
382
+ console.log('');
383
+ console.log(`Project linked to account "${accountName}".`);
384
+ return;
385
+ }
386
+ console.log('');
387
+ console.log('Starting new authentication...');
388
+ }
389
+ // Get API key and secret
390
+ let apiKey = process.env.NOSLOP_X_API_KEY;
391
+ let apiSecret = process.env.NOSLOP_X_API_SECRET;
392
+ if (!apiKey || !apiSecret) {
393
+ console.log('');
394
+ console.log('You need an X Developer App. Create one at:');
395
+ console.log(' https://developer.x.com/en/portal/dashboard');
396
+ console.log('');
397
+ console.log('Make sure to enable "Read and Write" permissions.');
398
+ console.log('');
399
+ apiKey = await prompt('Enter API Key (Consumer Key): ');
400
+ apiSecret = await prompt('Enter API Secret (Consumer Secret): ');
401
+ if (!apiKey || !apiSecret) {
402
+ console.log('API Key and Secret are required.');
403
+ process.exit(1);
404
+ }
405
+ }
406
+ else {
407
+ console.log('');
408
+ console.log('Using API credentials from environment variables.');
409
+ }
410
+ // Get request token
411
+ console.log('');
412
+ console.log('Starting authorization...');
413
+ let requestToken;
414
+ try {
415
+ requestToken = await getOAuthRequestToken(apiKey, apiSecret);
416
+ }
417
+ catch (error) {
418
+ console.log(`Failed to start authorization: ${error.message}`);
419
+ console.log('');
420
+ console.log('Check that your API Key and Secret are correct.');
258
421
  process.exit(1);
259
422
  }
260
- updateSchedule(item, datetime);
261
- console.log(`Scheduled: ${item.folder} @ ${datetime}`);
423
+ // Open browser for user authorization
424
+ console.log('');
425
+ console.log('Opening browser for authorization...');
426
+ console.log('');
427
+ console.log('If browser does not open, visit:');
428
+ console.log(` ${requestToken.authorizeUrl}`);
429
+ console.log('');
430
+ openBrowser(requestToken.authorizeUrl);
431
+ // Get PIN from user
432
+ const pin = await prompt('Enter the PIN from X: ');
433
+ if (!pin) {
434
+ console.log('PIN is required.');
435
+ process.exit(1);
436
+ }
437
+ // Exchange PIN for access tokens
438
+ console.log('');
439
+ console.log('Completing authorization...');
440
+ let accessTokenResponse;
441
+ try {
442
+ accessTokenResponse = await getOAuthAccessToken(apiKey, apiSecret, requestToken.oauthToken, requestToken.oauthTokenSecret, pin);
443
+ }
444
+ catch (error) {
445
+ console.log(`Authorization failed: ${error.message}`);
446
+ console.log('');
447
+ console.log('Make sure you entered the correct PIN.');
448
+ process.exit(1);
449
+ }
450
+ // Create credentials
451
+ const credentials = {
452
+ apiKey,
453
+ apiSecret,
454
+ accessToken: accessTokenResponse.accessToken,
455
+ accessTokenSecret: accessTokenResponse.accessTokenSecret,
456
+ screenName: accessTokenResponse.screenName,
457
+ createdAt: new Date().toISOString(),
458
+ updatedAt: new Date().toISOString(),
459
+ };
460
+ console.log('');
461
+ console.log(`Authorized as @${accessTokenResponse.screenName}`);
462
+ // Verify credentials work
463
+ const user = await verifyCredentials(credentials);
464
+ if (!user) {
465
+ saveXCredentials(accountName, credentials);
466
+ setProjectXAccount(accountName);
467
+ console.log('');
468
+ console.log('Credentials saved but verification failed.');
469
+ console.log('You may need to check your API permissions.');
470
+ console.log('');
471
+ console.log(`Project linked to account "${accountName}".`);
472
+ return;
473
+ }
474
+ saveXCredentials(accountName, credentials);
475
+ setProjectXAccount(accountName);
476
+ console.log('');
477
+ console.log('Authentication successful!');
478
+ console.log(` Account: ${accountName} (@${user.username})`);
479
+ console.log('');
480
+ console.log(`Project linked to account "${accountName}".`);
481
+ console.log('');
482
+ console.log('You can now post to X with: noslop x post <id>');
262
483
  });
263
- // Delete command
264
- program
265
- .command('delete')
266
- .description('Delete a draft')
267
- .argument('<id>', 'Folder name or ID')
268
- .action((id) => {
484
+ /**
485
+ * Get X credentials for the current project
486
+ * Exits with error if not authenticated
487
+ */
488
+ function getProjectCredentials() {
269
489
  if (!isNoslopProject()) {
270
490
  console.log('Not a noslop project. Run `noslop init` first.');
271
491
  process.exit(1);
272
492
  }
493
+ const accountName = getProjectXAccount();
494
+ if (!accountName) {
495
+ console.log('This project is not linked to an X account.');
496
+ console.log('Run `noslop auth x` to link an account.');
497
+ process.exit(1);
498
+ }
499
+ if (!hasXCredentials(accountName)) {
500
+ console.log(`Account "${accountName}" not found.`);
501
+ console.log('Run `noslop auth x` to re-authenticate.');
502
+ process.exit(1);
503
+ }
504
+ const creds = loadXCredentials(accountName);
505
+ if (!creds) {
506
+ console.log(`Failed to load credentials for "${accountName}".`);
507
+ process.exit(1);
508
+ }
509
+ return creds;
510
+ }
511
+ // X command group
512
+ const x = program.command('x').description('X (Twitter) integration commands');
513
+ x.command('post')
514
+ .description('Post a draft to X immediately')
515
+ .argument('<id>', 'Draft folder name or ID')
516
+ .action(async (id) => {
517
+ const creds = getProjectCredentials();
518
+ // Find the item
273
519
  const item = findItem(id);
274
520
  if (!item) {
275
521
  console.log(`Not found: ${id}`);
276
522
  process.exit(1);
277
523
  }
278
- deleteDraft(item);
279
- console.log(`Deleted: ${item.folder}`);
524
+ // Check if already posted
525
+ if (item.published?.startsWith('http')) {
526
+ console.log(`Already published: ${item.folder}`);
527
+ console.log(` URL: ${item.published}`);
528
+ process.exit(1);
529
+ }
530
+ // Validate content
531
+ if (!item.post || item.post.trim() === '') {
532
+ console.log(`Empty post content: ${item.folder}`);
533
+ process.exit(1);
534
+ }
535
+ // Post to X
536
+ console.log(`Posting to X: ${item.folder}`);
537
+ console.log('');
538
+ try {
539
+ const result = await postTweet(item.post, creds);
540
+ // Add published URL
541
+ addPublishedUrl(item, result.url);
542
+ // Move to posts folder if in drafts
543
+ if (item.path.includes('/drafts/')) {
544
+ moveToPosts(item);
545
+ }
546
+ console.log('Posted successfully!');
547
+ console.log(` URL: ${result.url}`);
548
+ }
549
+ catch (error) {
550
+ console.log(`Failed to post: ${error.message}`);
551
+ process.exit(1);
552
+ }
280
553
  });
281
554
  program.parse();
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Get the noslop config directory path
3
+ * Uses ~/.config/noslop following XDG Base Directory spec
4
+ */
5
+ export declare function getConfigDir(): string;
6
+ /**
7
+ * Get the X credentials directory path
8
+ */
9
+ export declare function getXCredentialsDir(): string;
10
+ /**
11
+ * Ensure config directories exist with proper permissions
12
+ */
13
+ export declare function ensureConfigDirs(): void;
14
+ /**
15
+ * X API credentials structure
16
+ */
17
+ export interface XCredentials {
18
+ apiKey: string;
19
+ apiSecret: string;
20
+ accessToken: string;
21
+ accessTokenSecret: string;
22
+ screenName?: string;
23
+ accountId?: string;
24
+ createdAt: string;
25
+ updatedAt: string;
26
+ }
27
+ /**
28
+ * Validate account name (alphanumeric, dashes, underscores)
29
+ */
30
+ export declare function isValidAccountName(name: string): boolean;
31
+ /**
32
+ * Get the path to X credentials file for an account
33
+ */
34
+ export declare function getXCredentialsPath(accountName: string): string;
35
+ /**
36
+ * Check if X credentials exist for an account
37
+ */
38
+ export declare function hasXCredentials(accountName: string): boolean;
39
+ /**
40
+ * Load X credentials from disk for an account
41
+ * @returns Credentials object or null if not found
42
+ */
43
+ export declare function loadXCredentials(accountName: string): XCredentials | null;
44
+ /**
45
+ * Save X credentials to disk with restricted permissions
46
+ * @param accountName - Account name to save under
47
+ * @param credentials - Credentials to save
48
+ */
49
+ export declare function saveXCredentials(accountName: string, credentials: XCredentials): void;
50
+ /**
51
+ * Delete X credentials from disk
52
+ */
53
+ export declare function deleteXCredentials(accountName: string): void;
54
+ /**
55
+ * List all X accounts with stored credentials
56
+ */
57
+ export declare function listXAccounts(): Array<{
58
+ name: string;
59
+ screenName?: string;
60
+ }>;
61
+ /**
62
+ * Validate that credentials have all required fields
63
+ */
64
+ export declare function validateXCredentials(creds: XCredentials): boolean;
65
+ /**
66
+ * Get the X account name from project's CLAUDE.md
67
+ */
68
+ export declare function getProjectXAccount(cwd?: string): string | null;
69
+ /**
70
+ * Set the X account name in project's CLAUDE.md
71
+ */
72
+ export declare function setProjectXAccount(accountName: string, cwd?: string): void;
73
+ /**
74
+ * Remove the X account from project's CLAUDE.md
75
+ */
76
+ export declare function removeProjectXAccount(cwd?: string): void;