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 +84 -4
- package/dist/index.js +321 -48
- package/dist/lib/config.d.ts +76 -0
- package/dist/lib/config.js +188 -0
- package/dist/lib/config.test.d.ts +1 -0
- package/dist/lib/config.test.js +226 -0
- package/dist/lib/content.d.ts +0 -7
- package/dist/lib/content.js +0 -42
- package/dist/lib/content.test.js +1 -54
- package/dist/lib/templates.d.ts +2 -2
- package/dist/lib/templates.js +15 -9
- package/dist/lib/x-api.d.ts +49 -0
- package/dist/lib/x-api.js +208 -0
- package/dist/lib/x-api.test.d.ts +1 -0
- package/dist/lib/x-api.test.js +23 -0
- package/dist/tui.js +9 -16
- package/package.json +6 -2
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
/**
|
|
5
|
+
* Get the noslop config directory path
|
|
6
|
+
* Uses ~/.config/noslop following XDG Base Directory spec
|
|
7
|
+
*/
|
|
8
|
+
export function getConfigDir() {
|
|
9
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
10
|
+
return path.join(xdgConfig, 'noslop');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Get the X credentials directory path
|
|
14
|
+
*/
|
|
15
|
+
export function getXCredentialsDir() {
|
|
16
|
+
return path.join(getConfigDir(), 'credentials', 'x');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Ensure config directories exist with proper permissions
|
|
20
|
+
*/
|
|
21
|
+
export function ensureConfigDirs() {
|
|
22
|
+
const configDir = getConfigDir();
|
|
23
|
+
const xCredentialsDir = getXCredentialsDir();
|
|
24
|
+
if (!fs.existsSync(configDir)) {
|
|
25
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
26
|
+
}
|
|
27
|
+
if (!fs.existsSync(xCredentialsDir)) {
|
|
28
|
+
fs.mkdirSync(xCredentialsDir, { recursive: true, mode: 0o700 });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Validate account name (alphanumeric, dashes, underscores)
|
|
33
|
+
*/
|
|
34
|
+
export function isValidAccountName(name) {
|
|
35
|
+
return /^[a-zA-Z0-9_-]+$/.test(name) && name.length > 0 && name.length <= 50;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Get the path to X credentials file for an account
|
|
39
|
+
*/
|
|
40
|
+
export function getXCredentialsPath(accountName) {
|
|
41
|
+
if (!isValidAccountName(accountName)) {
|
|
42
|
+
throw new Error(`Invalid account name: ${accountName}`);
|
|
43
|
+
}
|
|
44
|
+
return path.join(getXCredentialsDir(), `${accountName}.json`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check if X credentials exist for an account
|
|
48
|
+
*/
|
|
49
|
+
export function hasXCredentials(accountName) {
|
|
50
|
+
try {
|
|
51
|
+
return fs.existsSync(getXCredentialsPath(accountName));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Load X credentials from disk for an account
|
|
59
|
+
* @returns Credentials object or null if not found
|
|
60
|
+
*/
|
|
61
|
+
export function loadXCredentials(accountName) {
|
|
62
|
+
let credPath;
|
|
63
|
+
try {
|
|
64
|
+
credPath = getXCredentialsPath(accountName);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!fs.existsSync(credPath)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const content = fs.readFileSync(credPath, 'utf-8');
|
|
74
|
+
return JSON.parse(content);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Save X credentials to disk with restricted permissions
|
|
82
|
+
* @param accountName - Account name to save under
|
|
83
|
+
* @param credentials - Credentials to save
|
|
84
|
+
*/
|
|
85
|
+
export function saveXCredentials(accountName, credentials) {
|
|
86
|
+
ensureConfigDirs();
|
|
87
|
+
const credPath = getXCredentialsPath(accountName);
|
|
88
|
+
const content = JSON.stringify(credentials, null, 2);
|
|
89
|
+
// Write with restricted permissions (owner read/write only)
|
|
90
|
+
fs.writeFileSync(credPath, content, { mode: 0o600 });
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Delete X credentials from disk
|
|
94
|
+
*/
|
|
95
|
+
export function deleteXCredentials(accountName) {
|
|
96
|
+
let credPath;
|
|
97
|
+
try {
|
|
98
|
+
credPath = getXCredentialsPath(accountName);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (fs.existsSync(credPath)) {
|
|
104
|
+
fs.unlinkSync(credPath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* List all X accounts with stored credentials
|
|
109
|
+
*/
|
|
110
|
+
export function listXAccounts() {
|
|
111
|
+
const dir = getXCredentialsDir();
|
|
112
|
+
if (!fs.existsSync(dir)) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
116
|
+
const accounts = [];
|
|
117
|
+
for (const file of files) {
|
|
118
|
+
const name = file.replace('.json', '');
|
|
119
|
+
const creds = loadXCredentials(name);
|
|
120
|
+
accounts.push({
|
|
121
|
+
name,
|
|
122
|
+
screenName: creds?.screenName,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return accounts;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Validate that credentials have all required fields
|
|
129
|
+
*/
|
|
130
|
+
export function validateXCredentials(creds) {
|
|
131
|
+
return !!(creds.apiKey && creds.apiSecret && creds.accessToken && creds.accessTokenSecret);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Get the X account name from project's CLAUDE.md
|
|
135
|
+
*/
|
|
136
|
+
export function getProjectXAccount(cwd = process.cwd()) {
|
|
137
|
+
const claudeMdPath = path.join(cwd, 'CLAUDE.md');
|
|
138
|
+
if (!fs.existsSync(claudeMdPath)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const content = fs.readFileSync(claudeMdPath, 'utf-8');
|
|
143
|
+
const match = content.match(/## X Account\n([^\n#]+)/);
|
|
144
|
+
if (match) {
|
|
145
|
+
const accountName = match[1].trim();
|
|
146
|
+
return isValidAccountName(accountName) ? accountName : null;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Set the X account name in project's CLAUDE.md
|
|
156
|
+
*/
|
|
157
|
+
export function setProjectXAccount(accountName, cwd = process.cwd()) {
|
|
158
|
+
if (!isValidAccountName(accountName)) {
|
|
159
|
+
throw new Error(`Invalid account name: ${accountName}`);
|
|
160
|
+
}
|
|
161
|
+
const claudeMdPath = path.join(cwd, 'CLAUDE.md');
|
|
162
|
+
if (!fs.existsSync(claudeMdPath)) {
|
|
163
|
+
throw new Error('CLAUDE.md not found. Run `noslop init` first.');
|
|
164
|
+
}
|
|
165
|
+
let content = fs.readFileSync(claudeMdPath, 'utf-8');
|
|
166
|
+
if (content.includes('## X Account\n')) {
|
|
167
|
+
// Update existing section
|
|
168
|
+
content = content.replace(/## X Account\n[^\n#]*/, `## X Account\n${accountName}`);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// Add new section at the end
|
|
172
|
+
content = `${content.trim()}\n\n## X Account\n${accountName}\n`;
|
|
173
|
+
}
|
|
174
|
+
fs.writeFileSync(claudeMdPath, content);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Remove the X account from project's CLAUDE.md
|
|
178
|
+
*/
|
|
179
|
+
export function removeProjectXAccount(cwd = process.cwd()) {
|
|
180
|
+
const claudeMdPath = path.join(cwd, 'CLAUDE.md');
|
|
181
|
+
if (!fs.existsSync(claudeMdPath)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
let content = fs.readFileSync(claudeMdPath, 'utf-8');
|
|
185
|
+
// Remove the X Account section
|
|
186
|
+
content = content.replace(/\n*## X Account\n[^\n#]*\n?/, '\n');
|
|
187
|
+
fs.writeFileSync(claudeMdPath, `${content.trim()}\n`);
|
|
188
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { getConfigDir, getXCredentialsDir, ensureConfigDirs, getXCredentialsPath, hasXCredentials, loadXCredentials, saveXCredentials, deleteXCredentials, validateXCredentials, isValidAccountName, listXAccounts, getProjectXAccount, setProjectXAccount, removeProjectXAccount, } from './config.js';
|
|
6
|
+
describe('config', () => {
|
|
7
|
+
const originalXdgConfig = process.env.XDG_CONFIG_HOME;
|
|
8
|
+
let testConfigDir;
|
|
9
|
+
let testProjectDir;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Use a temp directory for tests
|
|
12
|
+
testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'noslop-test-'));
|
|
13
|
+
testProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'noslop-project-'));
|
|
14
|
+
process.env.XDG_CONFIG_HOME = testConfigDir;
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
// Restore original env
|
|
18
|
+
if (originalXdgConfig) {
|
|
19
|
+
process.env.XDG_CONFIG_HOME = originalXdgConfig;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
23
|
+
}
|
|
24
|
+
// Clean up test directories
|
|
25
|
+
if (testConfigDir && fs.existsSync(testConfigDir)) {
|
|
26
|
+
fs.rmSync(testConfigDir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
if (testProjectDir && fs.existsSync(testProjectDir)) {
|
|
29
|
+
fs.rmSync(testProjectDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
describe('getConfigDir', () => {
|
|
33
|
+
it('returns XDG_CONFIG_HOME/noslop when XDG_CONFIG_HOME is set', () => {
|
|
34
|
+
const dir = getConfigDir();
|
|
35
|
+
expect(dir).toBe(path.join(testConfigDir, 'noslop'));
|
|
36
|
+
});
|
|
37
|
+
it('returns ~/.config/noslop when XDG_CONFIG_HOME is not set', () => {
|
|
38
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
39
|
+
const dir = getConfigDir();
|
|
40
|
+
expect(dir).toBe(path.join(os.homedir(), '.config', 'noslop'));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('getXCredentialsDir', () => {
|
|
44
|
+
it('returns config dir + credentials/x', () => {
|
|
45
|
+
const dir = getXCredentialsDir();
|
|
46
|
+
expect(dir).toBe(path.join(testConfigDir, 'noslop', 'credentials', 'x'));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('ensureConfigDirs', () => {
|
|
50
|
+
it('creates config and credentials directories', () => {
|
|
51
|
+
ensureConfigDirs();
|
|
52
|
+
expect(fs.existsSync(getConfigDir())).toBe(true);
|
|
53
|
+
expect(fs.existsSync(getXCredentialsDir())).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
it('does not fail if directories already exist', () => {
|
|
56
|
+
ensureConfigDirs();
|
|
57
|
+
ensureConfigDirs(); // Should not throw
|
|
58
|
+
expect(fs.existsSync(getConfigDir())).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('isValidAccountName', () => {
|
|
62
|
+
it('accepts valid names', () => {
|
|
63
|
+
expect(isValidAccountName('personal')).toBe(true);
|
|
64
|
+
expect(isValidAccountName('work')).toBe(true);
|
|
65
|
+
expect(isValidAccountName('client-acme')).toBe(true);
|
|
66
|
+
expect(isValidAccountName('test_123')).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it('rejects invalid names', () => {
|
|
69
|
+
expect(isValidAccountName('')).toBe(false);
|
|
70
|
+
expect(isValidAccountName('has spaces')).toBe(false);
|
|
71
|
+
expect(isValidAccountName('has/slash')).toBe(false);
|
|
72
|
+
expect(isValidAccountName('../traversal')).toBe(false);
|
|
73
|
+
expect(isValidAccountName('a'.repeat(51))).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('X credentials', () => {
|
|
77
|
+
const accountName = 'personal';
|
|
78
|
+
const validCreds = {
|
|
79
|
+
apiKey: 'test-api-key',
|
|
80
|
+
apiSecret: 'test-api-secret',
|
|
81
|
+
accessToken: 'test-access-token',
|
|
82
|
+
accessTokenSecret: 'test-access-token-secret',
|
|
83
|
+
screenName: 'testuser',
|
|
84
|
+
accountId: 'test-account-id',
|
|
85
|
+
createdAt: '2026-01-24T00:00:00Z',
|
|
86
|
+
updatedAt: '2026-01-24T00:00:00Z',
|
|
87
|
+
};
|
|
88
|
+
describe('hasXCredentials', () => {
|
|
89
|
+
it('returns false when no credentials file exists', () => {
|
|
90
|
+
expect(hasXCredentials(accountName)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
it('returns true when credentials file exists', () => {
|
|
93
|
+
saveXCredentials(accountName, validCreds);
|
|
94
|
+
expect(hasXCredentials(accountName)).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
it('returns false for invalid account name', () => {
|
|
97
|
+
expect(hasXCredentials('../invalid')).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('saveXCredentials', () => {
|
|
101
|
+
it('saves credentials to disk', () => {
|
|
102
|
+
saveXCredentials(accountName, validCreds);
|
|
103
|
+
const credPath = getXCredentialsPath(accountName);
|
|
104
|
+
expect(fs.existsSync(credPath)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
it('creates directories if they do not exist', () => {
|
|
107
|
+
expect(fs.existsSync(getXCredentialsDir())).toBe(false);
|
|
108
|
+
saveXCredentials(accountName, validCreds);
|
|
109
|
+
expect(fs.existsSync(getXCredentialsDir())).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
it('throws for invalid account name', () => {
|
|
112
|
+
expect(() => saveXCredentials('../invalid', validCreds)).toThrow();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe('loadXCredentials', () => {
|
|
116
|
+
it('returns null when no credentials exist', () => {
|
|
117
|
+
expect(loadXCredentials(accountName)).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
it('loads saved credentials', () => {
|
|
120
|
+
saveXCredentials(accountName, validCreds);
|
|
121
|
+
const loaded = loadXCredentials(accountName);
|
|
122
|
+
expect(loaded).toEqual(validCreds);
|
|
123
|
+
});
|
|
124
|
+
it('returns null for invalid JSON', () => {
|
|
125
|
+
ensureConfigDirs();
|
|
126
|
+
fs.writeFileSync(getXCredentialsPath(accountName), 'not valid json', { mode: 0o600 });
|
|
127
|
+
expect(loadXCredentials(accountName)).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
it('returns null for invalid account name', () => {
|
|
130
|
+
expect(loadXCredentials('../invalid')).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe('deleteXCredentials', () => {
|
|
134
|
+
it('removes credentials file', () => {
|
|
135
|
+
saveXCredentials(accountName, validCreds);
|
|
136
|
+
expect(hasXCredentials(accountName)).toBe(true);
|
|
137
|
+
deleteXCredentials(accountName);
|
|
138
|
+
expect(hasXCredentials(accountName)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
it('does not fail if file does not exist', () => {
|
|
141
|
+
expect(() => deleteXCredentials(accountName)).not.toThrow();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
describe('listXAccounts', () => {
|
|
145
|
+
it('returns empty array when no accounts', () => {
|
|
146
|
+
expect(listXAccounts()).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
it('returns list of accounts', () => {
|
|
149
|
+
saveXCredentials('personal', validCreds);
|
|
150
|
+
saveXCredentials('work', { ...validCreds, screenName: 'workuser' });
|
|
151
|
+
const accounts = listXAccounts();
|
|
152
|
+
expect(accounts).toHaveLength(2);
|
|
153
|
+
expect(accounts.map(a => a.name)).toContain('personal');
|
|
154
|
+
expect(accounts.map(a => a.name)).toContain('work');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
describe('validateXCredentials', () => {
|
|
158
|
+
it('returns true for valid credentials', () => {
|
|
159
|
+
expect(validateXCredentials(validCreds)).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
it('returns false when apiKey is missing', () => {
|
|
162
|
+
expect(validateXCredentials({ ...validCreds, apiKey: '' })).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
it('returns false when apiSecret is missing', () => {
|
|
165
|
+
expect(validateXCredentials({ ...validCreds, apiSecret: '' })).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
it('returns false when accessToken is missing', () => {
|
|
168
|
+
expect(validateXCredentials({ ...validCreds, accessToken: '' })).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
it('returns false when accessTokenSecret is missing', () => {
|
|
171
|
+
expect(validateXCredentials({ ...validCreds, accessTokenSecret: '' })).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
describe('Project X account', () => {
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
// Create CLAUDE.md in test project
|
|
178
|
+
fs.writeFileSync(path.join(testProjectDir, 'CLAUDE.md'), '# Brand Guidelines\n\nSome content here.\n');
|
|
179
|
+
});
|
|
180
|
+
describe('getProjectXAccount', () => {
|
|
181
|
+
it('returns null when no X Account section', () => {
|
|
182
|
+
expect(getProjectXAccount(testProjectDir)).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
it('returns account name when set', () => {
|
|
185
|
+
fs.writeFileSync(path.join(testProjectDir, 'CLAUDE.md'), '# Brand Guidelines\n\n## X Account\npersonal\n');
|
|
186
|
+
expect(getProjectXAccount(testProjectDir)).toBe('personal');
|
|
187
|
+
});
|
|
188
|
+
it('returns null for invalid account name', () => {
|
|
189
|
+
fs.writeFileSync(path.join(testProjectDir, 'CLAUDE.md'), '# Brand Guidelines\n\n## X Account\n../invalid\n');
|
|
190
|
+
expect(getProjectXAccount(testProjectDir)).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('setProjectXAccount', () => {
|
|
194
|
+
it('adds X Account section when missing', () => {
|
|
195
|
+
setProjectXAccount('personal', testProjectDir);
|
|
196
|
+
const content = fs.readFileSync(path.join(testProjectDir, 'CLAUDE.md'), 'utf-8');
|
|
197
|
+
expect(content).toContain('## X Account\npersonal');
|
|
198
|
+
});
|
|
199
|
+
it('updates existing X Account section', () => {
|
|
200
|
+
fs.writeFileSync(path.join(testProjectDir, 'CLAUDE.md'), '# Brand Guidelines\n\n## X Account\nold-account\n\n## Other\n');
|
|
201
|
+
setProjectXAccount('new-account', testProjectDir);
|
|
202
|
+
const content = fs.readFileSync(path.join(testProjectDir, 'CLAUDE.md'), 'utf-8');
|
|
203
|
+
expect(content).toContain('## X Account\nnew-account');
|
|
204
|
+
expect(content).not.toContain('old-account');
|
|
205
|
+
});
|
|
206
|
+
it('throws for invalid account name', () => {
|
|
207
|
+
expect(() => setProjectXAccount('../invalid', testProjectDir)).toThrow();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe('removeProjectXAccount', () => {
|
|
211
|
+
it('removes X Account section', () => {
|
|
212
|
+
fs.writeFileSync(path.join(testProjectDir, 'CLAUDE.md'), '# Brand Guidelines\n\n## X Account\npersonal\n\n## Other\nContent\n');
|
|
213
|
+
removeProjectXAccount(testProjectDir);
|
|
214
|
+
const content = fs.readFileSync(path.join(testProjectDir, 'CLAUDE.md'), 'utf-8');
|
|
215
|
+
expect(content).not.toContain('## X Account');
|
|
216
|
+
expect(content).toContain('## Other');
|
|
217
|
+
});
|
|
218
|
+
it('does nothing when no X Account section', () => {
|
|
219
|
+
const original = fs.readFileSync(path.join(testProjectDir, 'CLAUDE.md'), 'utf-8');
|
|
220
|
+
removeProjectXAccount(testProjectDir);
|
|
221
|
+
const after = fs.readFileSync(path.join(testProjectDir, 'CLAUDE.md'), 'utf-8');
|
|
222
|
+
expect(after.trim()).toBe(original.trim());
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
package/dist/lib/content.d.ts
CHANGED
|
@@ -108,13 +108,6 @@ export declare function updateSchedule(item: ContentItem, datetime: string): voi
|
|
|
108
108
|
* @throws Error if move operation fails
|
|
109
109
|
*/
|
|
110
110
|
export declare function moveToPosts(item: ContentItem, cwd?: string): void;
|
|
111
|
-
/**
|
|
112
|
-
* Move a post back to the drafts folder
|
|
113
|
-
* @param item - Content item to move
|
|
114
|
-
* @param cwd - Working directory
|
|
115
|
-
* @throws Error if move operation fails
|
|
116
|
-
*/
|
|
117
|
-
export declare function moveToDrafts(item: ContentItem, cwd?: string): void;
|
|
118
111
|
/**
|
|
119
112
|
* Add a published URL to a post
|
|
120
113
|
* @param item - Content item to update
|
package/dist/lib/content.js
CHANGED
|
@@ -343,48 +343,6 @@ export function moveToPosts(item, cwd = process.cwd()) {
|
|
|
343
343
|
throw new Error(`Failed to move ${item.path} to ${newPath}: ${error.message}`);
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
|
-
/**
|
|
347
|
-
* Move a post back to the drafts folder
|
|
348
|
-
* @param item - Content item to move
|
|
349
|
-
* @param cwd - Working directory
|
|
350
|
-
* @throws Error if move operation fails
|
|
351
|
-
*/
|
|
352
|
-
export function moveToDrafts(item, cwd = process.cwd()) {
|
|
353
|
-
const { draftsDir } = getContentDirs(cwd);
|
|
354
|
-
// Ensure drafts directory exists
|
|
355
|
-
try {
|
|
356
|
-
if (!fs.existsSync(draftsDir)) {
|
|
357
|
-
fs.mkdirSync(draftsDir, { recursive: true });
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
catch (error) {
|
|
361
|
-
throw new Error(`Failed to create drafts directory: ${error.message}`);
|
|
362
|
-
}
|
|
363
|
-
// Add status field
|
|
364
|
-
if (fs.existsSync(item.xFile)) {
|
|
365
|
-
try {
|
|
366
|
-
let content = fs.readFileSync(item.xFile, 'utf-8');
|
|
367
|
-
if (!content.includes('## Status\n')) {
|
|
368
|
-
content = content.replace(/## Media\n/, `## Status\nready\n\n## Media\n`);
|
|
369
|
-
}
|
|
370
|
-
else {
|
|
371
|
-
content = content.replace(/## Status\n.+/, '## Status\nready');
|
|
372
|
-
}
|
|
373
|
-
fs.writeFileSync(item.xFile, content);
|
|
374
|
-
}
|
|
375
|
-
catch (error) {
|
|
376
|
-
throw new Error(`Failed to update file ${item.xFile}: ${error.message}`);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
// Move to drafts
|
|
380
|
-
const newPath = path.join(draftsDir, item.folder);
|
|
381
|
-
try {
|
|
382
|
-
fs.renameSync(item.path, newPath);
|
|
383
|
-
}
|
|
384
|
-
catch (error) {
|
|
385
|
-
throw new Error(`Failed to move ${item.path} to ${newPath}: ${error.message}`);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
346
|
/**
|
|
389
347
|
* Add a published URL to a post
|
|
390
348
|
* @param item - Content item to update
|
package/dist/lib/content.test.js
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
|
-
import { parseContent, findItem, createDraft, formatDate, sortBySchedule, isNoslopProject, getContentDirs, isValidUrl, updateStatus, updateSchedule, moveToPosts,
|
|
5
|
+
import { parseContent, findItem, createDraft, formatDate, sortBySchedule, isNoslopProject, getContentDirs, isValidUrl, updateStatus, updateSchedule, moveToPosts, addPublishedUrl, deleteDraft, } from './content.js';
|
|
6
6
|
describe('content.ts', () => {
|
|
7
7
|
let testDir;
|
|
8
8
|
beforeEach(() => {
|
|
@@ -390,59 +390,6 @@ Just content
|
|
|
390
390
|
expect(fs.existsSync(path.join(testDir, 'posts', 'test-post'))).toBe(true);
|
|
391
391
|
});
|
|
392
392
|
});
|
|
393
|
-
describe('moveToDrafts', () => {
|
|
394
|
-
it('moves post folder to drafts', () => {
|
|
395
|
-
const postsDir = path.join(testDir, 'posts');
|
|
396
|
-
const draftsDir = path.join(testDir, 'drafts');
|
|
397
|
-
const postDir = path.join(postsDir, 'test-post');
|
|
398
|
-
fs.mkdirSync(postDir, { recursive: true });
|
|
399
|
-
fs.mkdirSync(draftsDir, { recursive: true });
|
|
400
|
-
const xFile = path.join(postDir, 'x.md');
|
|
401
|
-
fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Media\nNone');
|
|
402
|
-
const item = {
|
|
403
|
-
id: 'P001',
|
|
404
|
-
folder: 'test-post',
|
|
405
|
-
title: 'Test',
|
|
406
|
-
post: 'content',
|
|
407
|
-
published: '',
|
|
408
|
-
media: 'None',
|
|
409
|
-
postedAt: '',
|
|
410
|
-
scheduledAt: '',
|
|
411
|
-
status: 'draft',
|
|
412
|
-
path: postDir,
|
|
413
|
-
xFile,
|
|
414
|
-
};
|
|
415
|
-
moveToDrafts(item, testDir);
|
|
416
|
-
expect(fs.existsSync(postDir)).toBe(false);
|
|
417
|
-
expect(fs.existsSync(path.join(draftsDir, 'test-post'))).toBe(true);
|
|
418
|
-
});
|
|
419
|
-
it('adds status field to file', () => {
|
|
420
|
-
const postsDir = path.join(testDir, 'posts');
|
|
421
|
-
const draftsDir = path.join(testDir, 'drafts');
|
|
422
|
-
const postDir = path.join(postsDir, 'test-post');
|
|
423
|
-
fs.mkdirSync(postDir, { recursive: true });
|
|
424
|
-
fs.mkdirSync(draftsDir, { recursive: true });
|
|
425
|
-
const xFile = path.join(postDir, 'x.md');
|
|
426
|
-
fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Media\nNone');
|
|
427
|
-
const item = {
|
|
428
|
-
id: 'P001',
|
|
429
|
-
folder: 'test-post',
|
|
430
|
-
title: 'Test',
|
|
431
|
-
post: 'content',
|
|
432
|
-
published: '',
|
|
433
|
-
media: 'None',
|
|
434
|
-
postedAt: '',
|
|
435
|
-
scheduledAt: '',
|
|
436
|
-
status: 'draft',
|
|
437
|
-
path: postDir,
|
|
438
|
-
xFile,
|
|
439
|
-
};
|
|
440
|
-
moveToDrafts(item, testDir);
|
|
441
|
-
const newXFile = path.join(draftsDir, 'test-post', 'x.md');
|
|
442
|
-
const content = fs.readFileSync(newXFile, 'utf-8');
|
|
443
|
-
expect(content).toContain('## Status\nready');
|
|
444
|
-
});
|
|
445
|
-
});
|
|
446
393
|
describe('addPublishedUrl', () => {
|
|
447
394
|
it('adds published URL to post', () => {
|
|
448
395
|
const postsDir = path.join(testDir, 'posts');
|
package/dist/lib/templates.d.ts
CHANGED
|
@@ -8,12 +8,12 @@ export declare const POST_TEMPLATE = "# {{TITLE}}\n\n## Post\n```\n\n```\n\n## S
|
|
|
8
8
|
* Template for NOSLOP.md - tool documentation
|
|
9
9
|
* This file is regenerated on every launch to ensure documentation is up-to-date
|
|
10
10
|
*/
|
|
11
|
-
export declare const NOSLOP_TEMPLATE = "# noslop - Content Workflow CLI\n\nA command-line tool for managing social media content with an interactive TUI.\n\n## Quick Start\n\n```bash\nnoslop # Open interactive TUI\nnoslop init # Initialize new project\nnoslop new \"My Post\" # Create new draft\n```\n\n## Commands\n\n### Core\n| Command | Description |\n|---------|-------------|\n| `noslop` | Open interactive TUI |\n| `noslop init` | Create folder structure + config files |\n| `noslop help` | Show all commands |\n\n### Content Management\n| Command | Description |\n|---------|-------------|\n| `noslop new <title>` | Create new draft with title |\n| `noslop list [--drafts|--posts]` | List content with schedule |\n| `noslop status` | Summary: counts, next scheduled |\n| `noslop show <id>` | Show full content of a post/draft |\n\n### Workflow Actions\n| Command | Description |\n|---------|-------------|\n| `noslop ready <id>` | Mark draft as ready to post |\n| `noslop unready <id>` | Mark draft as in-progress |\n| `noslop
|
|
11
|
+
export declare const NOSLOP_TEMPLATE = "# noslop - Content Workflow CLI\n\nA command-line tool for managing social media content with an interactive TUI.\n\n## Quick Start\n\n```bash\nnoslop # Open interactive TUI\nnoslop init # Initialize new project\nnoslop new \"My Post\" # Create new draft\n```\n\n## Commands\n\n### Core\n| Command | Description |\n|---------|-------------|\n| `noslop` | Open interactive TUI |\n| `noslop init` | Create folder structure + config files |\n| `noslop help` | Show all commands |\n\n### Content Management\n| Command | Description |\n|---------|-------------|\n| `noslop new <title>` | Create new draft with title |\n| `noslop list [--drafts|--posts]` | List content with schedule |\n| `noslop status` | Summary: counts, next scheduled |\n| `noslop show <id>` | Show full content of a post/draft |\n\n### Workflow Actions\n| Command | Description |\n|---------|-------------|\n| `noslop ready <id>` | Mark draft as ready to post |\n| `noslop unready <id>` | Mark draft as in-progress |\n| `noslop schedule <id> <datetime>` | Set/update scheduled time |\n| `noslop delete <id>` | Delete a draft |\n\n### X Integration\n| Command | Description |\n|---------|-------------|\n| `noslop auth x` | Authenticate with X API |\n| `noslop auth x --status` | Check authentication status |\n| `noslop auth x --list` | List all authenticated accounts |\n| `noslop auth x --unlink` | Remove account link from project |\n| `noslop auth x --logout <name>` | Remove stored credentials |\n| `noslop x post <id>` | Post draft to X immediately |\n\n### Examples\n```bash\n# Full workflow\nnoslop new \"Monday Motivation\"\nnoslop ready monday-motivation\nnoslop x post monday-motivation # Posts to X, moves to posts/\n```\n\n## Folder Structure\n\n```\nyour-project/\n\u251C\u2500\u2500 CLAUDE.md # Your brand voice & guidelines\n\u251C\u2500\u2500 NOSLOP.md # This file (tool documentation)\n\u251C\u2500\u2500 drafts/ # Work in progress\n\u2502 \u2514\u2500\u2500 post-name/\n\u2502 \u251C\u2500\u2500 x.md # Post content\n\u2502 \u2514\u2500\u2500 assets/ # Images, videos (optional)\n\u2514\u2500\u2500 posts/ # Published/scheduled content\n \u2514\u2500\u2500 post-name/\n \u251C\u2500\u2500 x.md\n \u2514\u2500\u2500 assets/\n```\n\n## Post File Format (x.md)\n\n```markdown\n# Post Title\n\n## Post\n\\`\\`\\`\nyour post content here\nlowercase, brand voice, etc.\n\\`\\`\\`\n\n## Status\ndraft | ready\n\n## Media\nDescription of media to create/attach\n\n## Scheduled\n2026-01-27 09:00\n\n## Posted\n2026-01-27 09:00\n\n## Published\nhttps://x.com/user/status/123\n```\n\n## TUI Keyboard Shortcuts\n\n| Key | Drafts Tab | Posts Tab |\n|-----|------------|-----------|\n| `Tab` | Switch to Posts | Switch to Drafts |\n| `\u2191/\u2193` | Navigate items | Navigate items |\n| `Enter` | Toggle ready/draft | Add URL |\n| `Space` | Move to Posts | \u2014 |\n| `Backspace` | Delete (drafts only) | \u2014 |\n| `s` | Toggle schedule view | Toggle schedule view |\n| `\u2190/\u2192` | Navigate weeks | Navigate weeks |\n| `q` | Quit | Quit |\n\n## Working with AI Assistants\n\nThis project is designed to work with Claude Code and other AI assistants:\n\n1. **Use CLI commands** instead of manual file editing\n2. **CLAUDE.md** contains your brand voice and guidelines\n3. **NOSLOP.md** (this file) documents all available commands\n\nThe AI should use commands like `noslop new`, `noslop ready`, `noslop x post`\ninstead of directly creating/moving files.\n";
|
|
12
12
|
/**
|
|
13
13
|
* Template for CLAUDE.md - user-specific brand content
|
|
14
14
|
* This file contains brand voice guidelines for AI assistants
|
|
15
15
|
*/
|
|
16
|
-
export declare const CLAUDE_TEMPLATE = "# Content Guide\n\n> This project uses noslop for content management. See NOSLOP.md for CLI commands.\n\n---\n\n## Brand Voice\n\n<!--\nINSTRUCTIONS FOR AI: If this section is empty, interview the user to fill it in.\nAsk about tone, style, what to avoid, platform preferences, etc.\n-->\n\n### Tone & Style\n\n\n### Do's\n\n\n### Don'ts\n\n\n---\n\n## Platform Guidelines\n\n<!-- Which platforms? What's different about each? -->\n\n### Twitter/X\n\n\n### Other Platforms\n\n\n---\n\n## Key Messaging\n\n### Taglines\n\n\n### Key Phrases\n\n\n### Topics to Cover\n\n\n---\n\n## Content Examples\n\n<!-- Add example posts as you create them -->\n\n### Good Examples\n\n\n### Transformations (before \u2192 after)\n\n\n---\n\n## Visual Elements\n\n### Emoji Policy\n\n\n### ASCII Art Library\n\n\n";
|
|
16
|
+
export declare const CLAUDE_TEMPLATE = "# Content Guide\n\n> This project uses noslop for content management. See NOSLOP.md for CLI commands.\n\n---\n\n## Brand Voice\n\n<!--\nINSTRUCTIONS FOR AI: If this section is empty, interview the user to fill it in.\nAsk about tone, style, what to avoid, platform preferences, etc.\n-->\n\n### Tone & Style\n\n\n### Do's\n\n\n### Don'ts\n\n\n---\n\n## Platform Guidelines\n\n<!-- Which platforms? What's different about each? -->\n\n### Twitter/X\n\n<!-- Run `noslop auth x` to connect your X account -->\n\n\n### Other Platforms\n\n\n---\n\n## Key Messaging\n\n### Taglines\n\n\n### Key Phrases\n\n\n### Topics to Cover\n\n\n---\n\n## Content Examples\n\n<!-- Add example posts as you create them -->\n\n### Good Examples\n\n\n### Transformations (before \u2192 after)\n\n\n---\n\n## Visual Elements\n\n### Emoji Policy\n\n\n### ASCII Art Library\n\n\n";
|
|
17
17
|
/**
|
|
18
18
|
* Ensure NOSLOP.md documentation file exists and is up-to-date
|
|
19
19
|
* Creates or overwrites NOSLOP.md in the specified directory with
|
package/dist/lib/templates.js
CHANGED
|
@@ -62,21 +62,25 @@ noslop new "My Post" # Create new draft
|
|
|
62
62
|
|---------|-------------|
|
|
63
63
|
| \`noslop ready <id>\` | Mark draft as ready to post |
|
|
64
64
|
| \`noslop unready <id>\` | Mark draft as in-progress |
|
|
65
|
-
| \`noslop post <id>\` | Move draft to posts folder |
|
|
66
|
-
| \`noslop unpost <id>\` | Move post back to drafts |
|
|
67
|
-
| \`noslop publish <id> <url>\` | Add published URL to post |
|
|
68
65
|
| \`noslop schedule <id> <datetime>\` | Set/update scheduled time |
|
|
69
66
|
| \`noslop delete <id>\` | Delete a draft |
|
|
70
67
|
|
|
68
|
+
### X Integration
|
|
69
|
+
| Command | Description |
|
|
70
|
+
|---------|-------------|
|
|
71
|
+
| \`noslop auth x\` | Authenticate with X API |
|
|
72
|
+
| \`noslop auth x --status\` | Check authentication status |
|
|
73
|
+
| \`noslop auth x --list\` | List all authenticated accounts |
|
|
74
|
+
| \`noslop auth x --unlink\` | Remove account link from project |
|
|
75
|
+
| \`noslop auth x --logout <name>\` | Remove stored credentials |
|
|
76
|
+
| \`noslop x post <id>\` | Post draft to X immediately |
|
|
77
|
+
|
|
71
78
|
### Examples
|
|
72
79
|
\`\`\`bash
|
|
73
80
|
# Full workflow
|
|
74
81
|
noslop new "Monday Motivation"
|
|
75
|
-
noslop schedule monday-motivation "2026-01-27 09:00"
|
|
76
82
|
noslop ready monday-motivation
|
|
77
|
-
|
|
78
|
-
noslop post monday-motivation
|
|
79
|
-
noslop publish monday-motivation "https://x.com/user/status/123"
|
|
83
|
+
noslop x post monday-motivation # Posts to X, moves to posts/
|
|
80
84
|
\`\`\`
|
|
81
85
|
|
|
82
86
|
## Folder Structure
|
|
@@ -129,7 +133,7 @@ https://x.com/user/status/123
|
|
|
129
133
|
| \`Tab\` | Switch to Posts | Switch to Drafts |
|
|
130
134
|
| \`↑/↓\` | Navigate items | Navigate items |
|
|
131
135
|
| \`Enter\` | Toggle ready/draft | Add URL |
|
|
132
|
-
| \`Space\` | Move to Posts |
|
|
136
|
+
| \`Space\` | Move to Posts | — |
|
|
133
137
|
| \`Backspace\` | Delete (drafts only) | — |
|
|
134
138
|
| \`s\` | Toggle schedule view | Toggle schedule view |
|
|
135
139
|
| \`←/→\` | Navigate weeks | Navigate weeks |
|
|
@@ -143,7 +147,7 @@ This project is designed to work with Claude Code and other AI assistants:
|
|
|
143
147
|
2. **CLAUDE.md** contains your brand voice and guidelines
|
|
144
148
|
3. **NOSLOP.md** (this file) documents all available commands
|
|
145
149
|
|
|
146
|
-
The AI should use commands like \`noslop new\`, \`noslop ready\`, \`noslop post\`
|
|
150
|
+
The AI should use commands like \`noslop new\`, \`noslop ready\`, \`noslop x post\`
|
|
147
151
|
instead of directly creating/moving files.
|
|
148
152
|
`;
|
|
149
153
|
/**
|
|
@@ -180,6 +184,8 @@ Ask about tone, style, what to avoid, platform preferences, etc.
|
|
|
180
184
|
|
|
181
185
|
### Twitter/X
|
|
182
186
|
|
|
187
|
+
<!-- Run \`noslop auth x\` to connect your X account -->
|
|
188
|
+
|
|
183
189
|
|
|
184
190
|
### Other Platforms
|
|
185
191
|
|