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.
Files changed (3) hide show
  1. package/README.md +29 -0
  2. package/bin/keep.js +302 -0
  3. 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
+ }