keep-markdown 0.1.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 +29 -0
- package/bin/keep.js +302 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# keep-markdown
|
|
2
|
+
|
|
3
|
+
CLI for [keep.md](https://keep.md) — search and retrieve saved web content as Markdown.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
1. Sign up for an account at https://keep.md
|
|
8
|
+
2. Create an API token at https://keep.md/dashboard
|
|
9
|
+
3. Save your token:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm i -g keep-markdown
|
|
13
|
+
keep key <your-token>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Alternatively set the `KEEP_API_KEY` environment variable.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
keep list --since 7d
|
|
22
|
+
keep search "react hooks"
|
|
23
|
+
keep get <id>
|
|
24
|
+
keep content <id>
|
|
25
|
+
keep me
|
|
26
|
+
keep stats
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Run `keep help` for the full list of commands and options.
|
package/bin/keep.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BASE_URL = 'https://keep.md'
|
|
7
|
+
|
|
8
|
+
const usage = () => `
|
|
9
|
+
keep <command> [options]
|
|
10
|
+
|
|
11
|
+
Commands:
|
|
12
|
+
key <token> Save API key for future use
|
|
13
|
+
list List items
|
|
14
|
+
search <query> Search items (shortcut for list --query)
|
|
15
|
+
get <id> Get item metadata
|
|
16
|
+
content <id> Get Markdown content
|
|
17
|
+
archive <id> Archive an item (hide from default list)
|
|
18
|
+
me Show account info
|
|
19
|
+
stats Get usage stats
|
|
20
|
+
|
|
21
|
+
Global options:
|
|
22
|
+
--base <url> Base API URL (env KEEP_API_BASE_URL)
|
|
23
|
+
--key <token> API key (env KEEP_API_KEY or KEEP_API_TOKEN)
|
|
24
|
+
--json Output raw JSON
|
|
25
|
+
|
|
26
|
+
List options:
|
|
27
|
+
--since <value> e.g. 7d, 24h, 2024-01-01
|
|
28
|
+
--until <value>
|
|
29
|
+
--status <list> comma-separated
|
|
30
|
+
--limit <n>
|
|
31
|
+
--offset <n>
|
|
32
|
+
--content Include markdown in list
|
|
33
|
+
--query <text> Filter by title, URL, notes, or tags
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
const parseArgs = (argv) => {
|
|
37
|
+
const flags = {}
|
|
38
|
+
const positionals = []
|
|
39
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
40
|
+
const arg = argv[i]
|
|
41
|
+
if (arg.startsWith('--')) {
|
|
42
|
+
const key = arg.slice(2)
|
|
43
|
+
const next = argv[i + 1]
|
|
44
|
+
if (next && !next.startsWith('--')) {
|
|
45
|
+
flags[key] = next
|
|
46
|
+
i += 1
|
|
47
|
+
} else {
|
|
48
|
+
flags[key] = true
|
|
49
|
+
}
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
positionals.push(arg)
|
|
53
|
+
}
|
|
54
|
+
return { flags, positionals }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const configDir = join(homedir(), '.config', 'keep')
|
|
58
|
+
const configFile = join(configDir, 'config.json')
|
|
59
|
+
const legacyConfigFile = join(
|
|
60
|
+
homedir(),
|
|
61
|
+
'.config',
|
|
62
|
+
'keep-markdown',
|
|
63
|
+
'config.json',
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const loadConfig = async () => {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(await readFile(configFile, 'utf8'))
|
|
69
|
+
} catch {
|
|
70
|
+
try {
|
|
71
|
+
// Backwards compatibility: earlier builds stored config under keep-markdown.
|
|
72
|
+
return JSON.parse(await readFile(legacyConfigFile, 'utf8'))
|
|
73
|
+
} catch {
|
|
74
|
+
return {}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const saveConfig = async (data) => {
|
|
80
|
+
await mkdir(configDir, { recursive: true })
|
|
81
|
+
await writeFile(configFile, JSON.stringify(data, null, 2) + '\n')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const requireAuth = async (flags) => {
|
|
85
|
+
const config = await loadConfig()
|
|
86
|
+
const baseUrl =
|
|
87
|
+
flags.base ||
|
|
88
|
+
process.env.KEEP_API_BASE_URL ||
|
|
89
|
+
config.baseUrl ||
|
|
90
|
+
DEFAULT_BASE_URL
|
|
91
|
+
const apiKey =
|
|
92
|
+
flags.key ||
|
|
93
|
+
process.env.KEEP_API_KEY ||
|
|
94
|
+
process.env.KEEP_API_TOKEN ||
|
|
95
|
+
config.apiKey
|
|
96
|
+
if (!apiKey) {
|
|
97
|
+
console.error('No API key found. Run: keep key <your-api-key>')
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
return { baseUrl: baseUrl.replace(/\/+$/, ''), apiKey }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const parseTimeInput = (value) => {
|
|
104
|
+
if (!value) return undefined
|
|
105
|
+
if (/^\d+$/.test(value)) return value
|
|
106
|
+
const match = value.match(/^(\d+)([smhd])$/i)
|
|
107
|
+
if (match) {
|
|
108
|
+
const amount = Number(match[1])
|
|
109
|
+
const unit = match[2].toLowerCase()
|
|
110
|
+
const multiplier =
|
|
111
|
+
unit === 's'
|
|
112
|
+
? 1000
|
|
113
|
+
: unit === 'm'
|
|
114
|
+
? 60_000
|
|
115
|
+
: unit === 'h'
|
|
116
|
+
? 3_600_000
|
|
117
|
+
: 86_400_000
|
|
118
|
+
return String(Date.now() - amount * multiplier)
|
|
119
|
+
}
|
|
120
|
+
const parsed = Date.parse(value)
|
|
121
|
+
if (Number.isFinite(parsed)) return String(parsed)
|
|
122
|
+
return value
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const apiFetch = async (baseUrl, apiKey, path, options = {}) => {
|
|
126
|
+
const headers = {
|
|
127
|
+
authorization: `Bearer ${apiKey}`,
|
|
128
|
+
...(options.headers || {}),
|
|
129
|
+
}
|
|
130
|
+
const res = await fetch(`${baseUrl}${path}`, { ...options, headers })
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
const body = await res.text().catch(() => '')
|
|
133
|
+
throw new Error(`Request failed (${res.status}): ${body}`)
|
|
134
|
+
}
|
|
135
|
+
return res
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const output = async (res, jsonOutput) => {
|
|
139
|
+
if (jsonOutput) {
|
|
140
|
+
const data = await res.json()
|
|
141
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const text = await res.text()
|
|
145
|
+
process.stdout.write(text.endsWith('\n') ? text : `${text}\n`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const printItems = (data) => {
|
|
149
|
+
const items = Array.isArray(data?.items) ? data.items : []
|
|
150
|
+
for (const item of items) {
|
|
151
|
+
const id = item?.id ?? ''
|
|
152
|
+
const url = item?.url ?? ''
|
|
153
|
+
const title = item?.title ?? ''
|
|
154
|
+
// Stable, script-friendly single-line output.
|
|
155
|
+
process.stdout.write(`${id}\t${url}\t${title}\n`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const listItems = async (flags) => {
|
|
160
|
+
const { baseUrl, apiKey } = await requireAuth(flags)
|
|
161
|
+
const params = new URLSearchParams()
|
|
162
|
+
const since = parseTimeInput(flags.since)
|
|
163
|
+
const until = parseTimeInput(flags.until)
|
|
164
|
+
if (since) params.set('since', since)
|
|
165
|
+
if (until) params.set('until', until)
|
|
166
|
+
if (flags.status) params.set('status', flags.status)
|
|
167
|
+
if (flags.limit) params.set('limit', flags.limit)
|
|
168
|
+
if (flags.offset) params.set('offset', flags.offset)
|
|
169
|
+
if (flags.content) params.set('content', '1')
|
|
170
|
+
if (flags.query) params.set('q', flags.query)
|
|
171
|
+
const res = await apiFetch(
|
|
172
|
+
baseUrl,
|
|
173
|
+
apiKey,
|
|
174
|
+
`/api/items${params.toString() ? `?${params}` : ''}`,
|
|
175
|
+
)
|
|
176
|
+
if (flags.json) {
|
|
177
|
+
await output(res, true)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
const data = await res.json()
|
|
181
|
+
printItems(data)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const getItem = async (id, flags) => {
|
|
185
|
+
const { baseUrl, apiKey } = await requireAuth(flags)
|
|
186
|
+
const params = new URLSearchParams()
|
|
187
|
+
if (flags.content) params.set('content', '1')
|
|
188
|
+
const res = await apiFetch(
|
|
189
|
+
baseUrl,
|
|
190
|
+
apiKey,
|
|
191
|
+
`/api/items/${id}${params.toString() ? `?${params}` : ''}`,
|
|
192
|
+
)
|
|
193
|
+
await output(res, true)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const getContent = async (id, flags) => {
|
|
197
|
+
const { baseUrl, apiKey } = await requireAuth(flags)
|
|
198
|
+
const res = await apiFetch(baseUrl, apiKey, `/api/items/${id}/content`)
|
|
199
|
+
await output(res, false)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const archiveItem = async (id, flags) => {
|
|
203
|
+
const { baseUrl, apiKey } = await requireAuth(flags)
|
|
204
|
+
const res = await apiFetch(baseUrl, apiKey, '/api/items/archive', {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: { 'content-type': 'application/json' },
|
|
207
|
+
body: JSON.stringify({ ids: [id] }),
|
|
208
|
+
})
|
|
209
|
+
await output(res, true)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const getMe = async (flags) => {
|
|
213
|
+
const { baseUrl, apiKey } = await requireAuth(flags)
|
|
214
|
+
const res = await apiFetch(baseUrl, apiKey, '/api/me')
|
|
215
|
+
await output(res, true)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const getStats = async (flags) => {
|
|
219
|
+
const { baseUrl, apiKey } = await requireAuth(flags)
|
|
220
|
+
const params = new URLSearchParams()
|
|
221
|
+
const since = parseTimeInput(flags.since)
|
|
222
|
+
const until = parseTimeInput(flags.until)
|
|
223
|
+
if (since) params.set('since', since)
|
|
224
|
+
if (until) params.set('until', until)
|
|
225
|
+
const res = await apiFetch(
|
|
226
|
+
baseUrl,
|
|
227
|
+
apiKey,
|
|
228
|
+
`/api/stats${params.toString() ? `?${params}` : ''}`,
|
|
229
|
+
)
|
|
230
|
+
await output(res, Boolean(flags.json))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const main = async () => {
|
|
234
|
+
const [command, ...rest] = process.argv.slice(2)
|
|
235
|
+
if (!command || command === 'help' || command === '--help') {
|
|
236
|
+
process.stdout.write(usage())
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
const { flags, positionals } = parseArgs(rest)
|
|
240
|
+
|
|
241
|
+
if (command === 'key') {
|
|
242
|
+
const token = positionals[0]
|
|
243
|
+
if (!token)
|
|
244
|
+
throw new Error('Missing API key. Usage: keep key <your-api-key>')
|
|
245
|
+
const config = await loadConfig()
|
|
246
|
+
config.apiKey = token
|
|
247
|
+
await saveConfig(config)
|
|
248
|
+
process.stdout.write('API key saved.\n')
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (command === 'list') {
|
|
253
|
+
await listItems(flags)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (command === 'search') {
|
|
258
|
+
const query = positionals[0]
|
|
259
|
+
if (!query) throw new Error('Missing search query.')
|
|
260
|
+
flags.query = query
|
|
261
|
+
await listItems(flags)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (command === 'get') {
|
|
266
|
+
const id = positionals[0]
|
|
267
|
+
if (!id) throw new Error('Missing item id.')
|
|
268
|
+
await getItem(id, flags)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (command === 'content') {
|
|
273
|
+
const id = positionals[0]
|
|
274
|
+
if (!id) throw new Error('Missing item id.')
|
|
275
|
+
await getContent(id, flags)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (command === 'archive') {
|
|
280
|
+
const id = positionals[0]
|
|
281
|
+
if (!id) throw new Error('Missing item id.')
|
|
282
|
+
await archiveItem(id, flags)
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (command === 'me') {
|
|
287
|
+
await getMe(flags)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (command === 'stats') {
|
|
292
|
+
await getStats(flags)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
throw new Error(`Unknown command: ${command}`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
main().catch((error) => {
|
|
300
|
+
console.error(error?.message || error)
|
|
301
|
+
process.exit(1)
|
|
302
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "keep-markdown",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for keep.md — search and retrieve saved web content as Markdown",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://keep.md",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/iannuttall/keep.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
13
|
+
"author": "Ian Nuttall",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"keep",
|
|
16
|
+
"markdown",
|
|
17
|
+
"cli",
|
|
18
|
+
"bookmarks",
|
|
19
|
+
"web-content"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"keep": "bin/keep.js",
|
|
23
|
+
"keep-markdown": "bin/keep.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin/",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
}
|
|
32
|
+
}
|