taskswipe-mcp 0.1.0 → 0.2.1
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 +51 -10
- package/package.json +2 -2
- package/src/brief.js +384 -0
- package/src/index.js +10 -5
- package/src/ob1.js +104 -0
- package/src/os.js +367 -0
- package/src/setup.js +182 -0
- package/src/store.js +63 -20
- package/src/tools.js +361 -7
package/src/os.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// CALLUM OS — read the life wiki (kalz-D/callum-os).
|
|
3
|
+
//
|
|
4
|
+
// GitHub is the canonical copy. Hermes also keeps a checkout at
|
|
5
|
+
// /opt/data/callum-os; that is used when no GitHub token is present so the
|
|
6
|
+
// VPS agent can still search the vault. TASKKA's serverless path always uses
|
|
7
|
+
// GitHub.
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_REPO = 'kalz-D/callum-os'
|
|
14
|
+
export const DEFAULT_OWNER_EMAIL = 'c.dempsey@live.com.au'
|
|
15
|
+
|
|
16
|
+
const SKIP_DIR = /(?:^|\/)(?:\.obsidian|\.git|node_modules)(?:\/|$)/
|
|
17
|
+
const NOTE_FILE = /\.(md|txt|markdown)$/i
|
|
18
|
+
const INDEX_PATH = 'index.md'
|
|
19
|
+
|
|
20
|
+
function repo() {
|
|
21
|
+
return String(process.env.CALLUM_OS_REPO || DEFAULT_REPO).replace(/^\/+|\/+$/g, '')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function token() {
|
|
25
|
+
return process.env.CALLUM_OS_GITHUB_TOKEN || process.env.GITHUB_TOKEN || ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function localDir() {
|
|
29
|
+
const hinted = String(process.env.CALLUM_OS_DIR || '').trim()
|
|
30
|
+
if (hinted) return hinted
|
|
31
|
+
if (fs.existsSync('/opt/data/callum-os/index.md')) return '/opt/data/callum-os'
|
|
32
|
+
return ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function osConfigured() {
|
|
36
|
+
return !!(token() || localDir())
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ownerEmails() {
|
|
40
|
+
return String(process.env.CALLUM_OS_OWNER_EMAIL || DEFAULT_OWNER_EMAIL)
|
|
41
|
+
.split(/[,\s]+/)
|
|
42
|
+
.map(s => s.trim().toLowerCase())
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isOsOwner(user) {
|
|
47
|
+
const email = String(user?.email || user?.user_metadata?.email || '').toLowerCase()
|
|
48
|
+
return !!email && ownerEmails().includes(email)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function safePath(value) {
|
|
52
|
+
const raw = String(value || '').replace(/\\/g, '/').replace(/^\/+/, '')
|
|
53
|
+
if (!raw || raw.includes('..') || raw.startsWith('/') || SKIP_DIR.test(raw)) return ''
|
|
54
|
+
return raw.slice(0, 400)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isNote(p) {
|
|
58
|
+
return !!p && !SKIP_DIR.test(p) && NOTE_FILE.test(p)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function gh(pathname, { method = 'GET', body, headers = {}, ok } = {}) {
|
|
62
|
+
const auth = token()
|
|
63
|
+
if (!auth) throw Object.assign(new Error('os_unconfigured'), { status: 503 })
|
|
64
|
+
const res = await fetch(`https://api.github.com${pathname}`, {
|
|
65
|
+
method,
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${auth}`,
|
|
68
|
+
Accept: 'application/vnd.github+json',
|
|
69
|
+
'User-Agent': 'taskka-callum-os',
|
|
70
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
71
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
72
|
+
...headers,
|
|
73
|
+
},
|
|
74
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
75
|
+
})
|
|
76
|
+
const data = await res.json().catch(() => ({}))
|
|
77
|
+
const allowed = ok || (method === 'GET' ? [200] : [200, 201])
|
|
78
|
+
if (!allowed.includes(res.status)) {
|
|
79
|
+
const err = new Error(data.message || `github_${res.status}`)
|
|
80
|
+
err.status = res.status === 403 ? 503 : res.status
|
|
81
|
+
throw err
|
|
82
|
+
}
|
|
83
|
+
return data
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let treeCache = { at: 0, files: [], sha: '' }
|
|
87
|
+
|
|
88
|
+
async function githubTree() {
|
|
89
|
+
if (treeCache.files.length && Date.now() - treeCache.at < 120_000) return treeCache
|
|
90
|
+
const head = await gh(`/repos/${repo()}/commits/main`)
|
|
91
|
+
const sha = head.commit?.tree?.sha || head.sha
|
|
92
|
+
const body = await gh(`/repos/${repo()}/git/trees/${sha}?recursive=1`)
|
|
93
|
+
const files = (body.tree || [])
|
|
94
|
+
.filter(n => n.type === 'blob' && isNote(n.path))
|
|
95
|
+
.map(n => ({ path: n.path, size: n.size || 0, sha: n.sha }))
|
|
96
|
+
treeCache = { at: Date.now(), files, sha }
|
|
97
|
+
return treeCache
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walkLocal(dir, prefix = '') {
|
|
101
|
+
const out = []
|
|
102
|
+
let entries = []
|
|
103
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return out }
|
|
104
|
+
for (const ent of entries) {
|
|
105
|
+
const rel = prefix ? `${prefix}/${ent.name}` : ent.name
|
|
106
|
+
if (SKIP_DIR.test(rel)) continue
|
|
107
|
+
const full = path.join(dir, ent.name)
|
|
108
|
+
if (ent.isDirectory()) out.push(...walkLocal(full, rel))
|
|
109
|
+
else if (ent.isFile() && isNote(rel)) {
|
|
110
|
+
let size = 0
|
|
111
|
+
try { size = fs.statSync(full).size } catch { /* ignore */ }
|
|
112
|
+
out.push({ path: rel, size, sha: '' })
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function listNotes() {
|
|
119
|
+
const root = localDir()
|
|
120
|
+
if (!token() && root) {
|
|
121
|
+
return { repo: 'local', sha: 'local', files: walkLocal(root) }
|
|
122
|
+
}
|
|
123
|
+
const t = await githubTree()
|
|
124
|
+
return { repo: repo(), sha: t.sha, files: t.files }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function folderOf(p) {
|
|
128
|
+
const i = p.indexOf('/')
|
|
129
|
+
return i < 0 ? 'ROOT' : p.slice(0, i)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function foldersFrom(files) {
|
|
133
|
+
const map = new Map()
|
|
134
|
+
for (const f of files) {
|
|
135
|
+
const folder = folderOf(f.path)
|
|
136
|
+
if (!map.has(folder)) map.set(folder, [])
|
|
137
|
+
map.get(folder).push(f)
|
|
138
|
+
}
|
|
139
|
+
return [...map.entries()].map(([name, items]) => ({
|
|
140
|
+
name,
|
|
141
|
+
count: items.length,
|
|
142
|
+
files: items.sort((a, b) => a.path.localeCompare(b.path)),
|
|
143
|
+
}))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function readNote(notePath) {
|
|
147
|
+
const p = safePath(notePath) || INDEX_PATH
|
|
148
|
+
if (!isNote(p) && p !== INDEX_PATH) {
|
|
149
|
+
throw Object.assign(new Error('bad_path'), { status: 400 })
|
|
150
|
+
}
|
|
151
|
+
const root = localDir()
|
|
152
|
+
if (!token() && root) {
|
|
153
|
+
const full = path.join(root, p)
|
|
154
|
+
if (!full.startsWith(path.resolve(root))) {
|
|
155
|
+
throw Object.assign(new Error('bad_path'), { status: 400 })
|
|
156
|
+
}
|
|
157
|
+
const text = fs.readFileSync(full, 'utf8')
|
|
158
|
+
return { path: p, text, sha: '', source: 'local' }
|
|
159
|
+
}
|
|
160
|
+
const encoded = p.split('/').map(encodeURIComponent).join('/')
|
|
161
|
+
const data = await gh(`/repos/${repo()}/contents/${encoded}?ref=main`)
|
|
162
|
+
const text = Buffer.from(data.content || '', 'base64').toString('utf8')
|
|
163
|
+
return { path: data.path || p, text, sha: data.sha || '', source: 'github', htmlUrl: data.html_url || null }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function snippetAround(text, query, span = 140) {
|
|
167
|
+
const lower = text.toLowerCase()
|
|
168
|
+
const q = query.toLowerCase()
|
|
169
|
+
const i = lower.indexOf(q)
|
|
170
|
+
if (i < 0) return text.slice(0, span).trim()
|
|
171
|
+
const start = Math.max(0, i - 40)
|
|
172
|
+
const end = Math.min(text.length, i + q.length + span)
|
|
173
|
+
return `${start > 0 ? '…' : ''}${text.slice(start, end).trim()}${end < text.length ? '…' : ''}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function searchNotes(query, limit = 20) {
|
|
177
|
+
const q = String(query || '').trim()
|
|
178
|
+
if (q.length < 2) return { query: q, results: [] }
|
|
179
|
+
const cap = Math.max(1, Math.min(40, Number(limit) || 20))
|
|
180
|
+
const { files, repo: repoName } = await listNotes()
|
|
181
|
+
const needle = q.toLowerCase()
|
|
182
|
+
const pathHits = files
|
|
183
|
+
.filter(f => f.path.toLowerCase().includes(needle))
|
|
184
|
+
.map(f => ({ path: f.path, how: 'path', snippet: f.path }))
|
|
185
|
+
|
|
186
|
+
const contentHits = []
|
|
187
|
+
if (token()) {
|
|
188
|
+
try {
|
|
189
|
+
const data = await gh(
|
|
190
|
+
`/search/code?q=${encodeURIComponent(`${q} repo:${repo()} extension:md`)}`,
|
|
191
|
+
{ headers: { Accept: 'application/vnd.github.text-match+json' } },
|
|
192
|
+
)
|
|
193
|
+
for (const item of data.items || []) {
|
|
194
|
+
const p = item.path
|
|
195
|
+
if (!isNote(p)) continue
|
|
196
|
+
const frag = item.text_matches?.[0]?.fragment || p
|
|
197
|
+
contentHits.push({ path: p, how: 'content', snippet: String(frag).replace(/\s+/g, ' ').slice(0, 220) })
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Code search is extra; path hits still work.
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
const root = localDir()
|
|
204
|
+
if (root) {
|
|
205
|
+
for (const f of files) {
|
|
206
|
+
try {
|
|
207
|
+
const text = fs.readFileSync(path.join(root, f.path), 'utf8')
|
|
208
|
+
if (text.toLowerCase().includes(needle)) {
|
|
209
|
+
contentHits.push({ path: f.path, how: 'content', snippet: snippetAround(text, q) })
|
|
210
|
+
}
|
|
211
|
+
} catch { /* skip unreadable */ }
|
|
212
|
+
if (contentHits.length >= cap) break
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const seen = new Set()
|
|
218
|
+
const results = []
|
|
219
|
+
for (const hit of [...pathHits, ...contentHits]) {
|
|
220
|
+
const key = hit.path.toLowerCase()
|
|
221
|
+
if (seen.has(key)) continue
|
|
222
|
+
seen.add(key)
|
|
223
|
+
results.push(hit)
|
|
224
|
+
if (results.length >= cap) break
|
|
225
|
+
}
|
|
226
|
+
return { query: q, repo: repoName, count: results.length, results }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function osStatus() {
|
|
230
|
+
const configured = osConfigured()
|
|
231
|
+
if (!configured) {
|
|
232
|
+
return { configured: false, writable: false, repo: repo(), files: 0, source: null }
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const { files, repo: repoName, sha } = await listNotes()
|
|
236
|
+
return {
|
|
237
|
+
configured: true,
|
|
238
|
+
writable: true,
|
|
239
|
+
repo: repoName,
|
|
240
|
+
files: files.length,
|
|
241
|
+
sha,
|
|
242
|
+
source: token() ? 'github' : 'local',
|
|
243
|
+
}
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return { configured: true, writable: !!token() || !!localDir(), repo: repo(), files: 0, error: e.message || 'os_status_failed' }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const MAX_NOTE_BYTES = 400_000
|
|
250
|
+
|
|
251
|
+
export function looksLikeSecret(text) {
|
|
252
|
+
return /ghp_[A-Za-z0-9]|gho_[A-Za-z0-9]|github_pat_|sk-ant-|sk-proj-|sk_live_|BEGIN [A-Z ]*PRIVATE KEY/i.test(String(text || ''))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function ensureFrontmatter(text, { title, type = 'Note' } = {}) {
|
|
256
|
+
const src = String(text || '')
|
|
257
|
+
if (src.startsWith('---')) return src
|
|
258
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
259
|
+
const safeTitle = String(title || 'Note').replace(/[\r\n]+/g, ' ').slice(0, 120)
|
|
260
|
+
return `---\ntype: ${type}\ntitle: ${safeTitle}\ndate: ${date}\nsource: taskka\n---\n\n${src}`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function notePathFrom({ folder, name } = {}) {
|
|
264
|
+
const file = String(name || '')
|
|
265
|
+
.replace(/\.md$/i, '')
|
|
266
|
+
.replace(/[^\w\s./-]+/g, '')
|
|
267
|
+
.trim()
|
|
268
|
+
.replace(/\s+/g, ' ')
|
|
269
|
+
if (!file || file.includes('..')) return ''
|
|
270
|
+
const folderRaw = String(folder || '').trim()
|
|
271
|
+
const folderPath = !folderRaw || folderRaw === 'ROOT' ? '' : safePath(folderRaw)
|
|
272
|
+
const rel = folderPath ? `${folderPath}/${file}.md` : `${file}.md`
|
|
273
|
+
return isNote(rel) ? rel : ''
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function bustTree() {
|
|
277
|
+
treeCache = { at: 0, files: [], sha: '' }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function writeNote({ path: notePath, text, message, sha } = {}) {
|
|
281
|
+
const p = safePath(notePath)
|
|
282
|
+
if (!isNote(p)) throw Object.assign(new Error('bad_path'), { status: 400 })
|
|
283
|
+
const body = String(text || '')
|
|
284
|
+
if (!body.trim()) throw Object.assign(new Error('empty_note'), { status: 400 })
|
|
285
|
+
if (Buffer.byteLength(body, 'utf8') > MAX_NOTE_BYTES) {
|
|
286
|
+
throw Object.assign(new Error('note_too_large'), { status: 413 })
|
|
287
|
+
}
|
|
288
|
+
if (looksLikeSecret(body)) {
|
|
289
|
+
throw Object.assign(new Error('secrets_forbidden'), { status: 400 })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const root = localDir()
|
|
293
|
+
if (!token() && root) {
|
|
294
|
+
const full = path.join(root, p)
|
|
295
|
+
if (!full.startsWith(path.resolve(root))) {
|
|
296
|
+
throw Object.assign(new Error('bad_path'), { status: 400 })
|
|
297
|
+
}
|
|
298
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
299
|
+
const created = !fs.existsSync(full)
|
|
300
|
+
fs.writeFileSync(full, body, 'utf8')
|
|
301
|
+
bustTree()
|
|
302
|
+
return { path: p, sha: '', created, source: 'local' }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let currentSha = sha || ''
|
|
306
|
+
if (!currentSha) {
|
|
307
|
+
try {
|
|
308
|
+
const existing = await readNote(p)
|
|
309
|
+
currentSha = existing.sha || ''
|
|
310
|
+
} catch (e) {
|
|
311
|
+
if (e.status !== 404) throw e
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const payload = {
|
|
316
|
+
message: String(message || `TASKKA: ${currentSha ? 'update' : 'create'} ${p}`).slice(0, 200),
|
|
317
|
+
content: Buffer.from(body, 'utf8').toString('base64'),
|
|
318
|
+
branch: 'main',
|
|
319
|
+
}
|
|
320
|
+
if (currentSha) payload.sha = currentSha
|
|
321
|
+
|
|
322
|
+
const encoded = p.split('/').map(encodeURIComponent).join('/')
|
|
323
|
+
const put = () => gh(`/repos/${repo()}/contents/${encoded}`, { method: 'PUT', body: payload })
|
|
324
|
+
let data
|
|
325
|
+
try {
|
|
326
|
+
data = await put()
|
|
327
|
+
} catch (e) {
|
|
328
|
+
if (e.status !== 409 && e.status !== 422) throw e
|
|
329
|
+
const existing = await readNote(p)
|
|
330
|
+
payload.sha = existing.sha
|
|
331
|
+
data = await put()
|
|
332
|
+
}
|
|
333
|
+
bustTree()
|
|
334
|
+
return {
|
|
335
|
+
path: data.content?.path || p,
|
|
336
|
+
sha: data.content?.sha || '',
|
|
337
|
+
created: !currentSha,
|
|
338
|
+
htmlUrl: data.content?.html_url || null,
|
|
339
|
+
source: 'github',
|
|
340
|
+
commit: data.commit?.sha || null,
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function appendNote({ path: notePath, text, message } = {}) {
|
|
345
|
+
const addition = String(text || '').trim()
|
|
346
|
+
if (!addition) throw Object.assign(new Error('empty_note'), { status: 400 })
|
|
347
|
+
if (looksLikeSecret(addition)) {
|
|
348
|
+
throw Object.assign(new Error('secrets_forbidden'), { status: 400 })
|
|
349
|
+
}
|
|
350
|
+
let existing
|
|
351
|
+
try {
|
|
352
|
+
existing = await readNote(notePath)
|
|
353
|
+
} catch (e) {
|
|
354
|
+
if (e.status === 404) {
|
|
355
|
+
return writeNote({ path: notePath, text: addition.endsWith('\n') ? addition : `${addition}\n`, message })
|
|
356
|
+
}
|
|
357
|
+
throw e
|
|
358
|
+
}
|
|
359
|
+
const base = existing.text || ''
|
|
360
|
+
const next = `${base}${base.endsWith('\n') ? '' : '\n'}${addition}\n`
|
|
361
|
+
return writeNote({
|
|
362
|
+
path: existing.path || notePath,
|
|
363
|
+
text: next,
|
|
364
|
+
sha: existing.sha,
|
|
365
|
+
message: message || `TASKKA: append ${existing.path || notePath}`,
|
|
366
|
+
})
|
|
367
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// One-time, interactive bootstrap for an always-on TASKKA agent.
|
|
2
|
+
//
|
|
3
|
+
// The account password is read from the terminal with echo disabled, used once
|
|
4
|
+
// to mint a dedicated Supabase session, and never written to disk. The agent
|
|
5
|
+
// then runs taskswipe-mcp without credentials in its config: the renewable
|
|
6
|
+
// session lives in ~/.taskswipe-mcp-session.json with owner-only permissions.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import process from 'node:process'
|
|
10
|
+
import { persistSession, SESSION_FILE, supa } from './store.js'
|
|
11
|
+
|
|
12
|
+
const SKILL_URL = 'https://taskka.app/.well-known/skills/taskka/SKILL.md'
|
|
13
|
+
const FOOD_ORDERING_SKILL_URL = 'https://taskka.app/.well-known/skills/food-ordering/SKILL.md'
|
|
14
|
+
const PACKAGE = 'taskswipe-mcp@latest'
|
|
15
|
+
|
|
16
|
+
export function parseSetupArgs(argv = []) {
|
|
17
|
+
const target = String(argv[0] || '').toLowerCase()
|
|
18
|
+
let email = ''
|
|
19
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
20
|
+
if (argv[i] === '--email') email = String(argv[++i] || '').trim()
|
|
21
|
+
else if (String(argv[i]).startsWith('--email=')) email = String(argv[i]).slice(8).trim()
|
|
22
|
+
else throw new Error(`Unknown setup option: ${argv[i]}`)
|
|
23
|
+
}
|
|
24
|
+
if (!['hermes', 'openclaw'].includes(target)) {
|
|
25
|
+
throw new Error('Usage: taskswipe-mcp setup <hermes|openclaw> [--email you@example.com]')
|
|
26
|
+
}
|
|
27
|
+
return { target, email }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function setupPlan(target) {
|
|
31
|
+
if (target === 'hermes') {
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
label: 'Registering TASKKA with Hermes',
|
|
35
|
+
command: 'hermes',
|
|
36
|
+
args: ['mcp', 'add', 'taskka', '--command', 'npx', '--args', '-y', PACKAGE],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: 'Installing the TASKKA skill',
|
|
40
|
+
command: 'hermes',
|
|
41
|
+
args: ['skills', 'install', SKILL_URL],
|
|
42
|
+
optional: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: 'Installing the guarded food-ordering skill',
|
|
46
|
+
command: 'hermes',
|
|
47
|
+
args: ['skills', 'install', FOOD_ORDERING_SKILL_URL],
|
|
48
|
+
optional: true,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
label: 'Testing the MCP connection',
|
|
52
|
+
command: 'hermes',
|
|
53
|
+
args: ['mcp', 'test', 'taskka'],
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
if (target === 'openclaw') {
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
label: 'Registering TASKKA with OpenClaw',
|
|
61
|
+
command: 'openclaw',
|
|
62
|
+
args: ['mcp', 'add', 'taskka', '--command', 'npx', '--arg', '-y', '--arg', PACKAGE],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
label: 'Installing the TASKKA skill',
|
|
66
|
+
command: 'openclaw',
|
|
67
|
+
args: ['skills', 'install', SKILL_URL],
|
|
68
|
+
optional: true,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
label: 'Testing the MCP connection',
|
|
72
|
+
command: 'openclaw',
|
|
73
|
+
args: ['mcp', 'doctor', 'taskka', '--probe'],
|
|
74
|
+
},
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Unsupported setup target: ${target}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readVisible(question) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
process.stdout.write(question)
|
|
83
|
+
let value = ''
|
|
84
|
+
const onData = chunk => {
|
|
85
|
+
const text = chunk.toString('utf8')
|
|
86
|
+
const end = text.indexOf('\n')
|
|
87
|
+
if (end !== -1) {
|
|
88
|
+
cleanup()
|
|
89
|
+
resolve((value + text.slice(0, end)).replace(/\r$/, '').trim())
|
|
90
|
+
} else value += text
|
|
91
|
+
}
|
|
92
|
+
const onError = error => { cleanup(); reject(error) }
|
|
93
|
+
const cleanup = () => {
|
|
94
|
+
process.stdin.off('data', onData)
|
|
95
|
+
process.stdin.off('error', onError)
|
|
96
|
+
process.stdin.pause()
|
|
97
|
+
}
|
|
98
|
+
process.stdin.on('data', onData)
|
|
99
|
+
process.stdin.on('error', onError)
|
|
100
|
+
process.stdin.resume()
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readSecret(question) {
|
|
105
|
+
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
106
|
+
throw new Error('Password entry needs an interactive terminal. Re-run this command directly over SSH.')
|
|
107
|
+
}
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const value = []
|
|
110
|
+
process.stdout.write(question)
|
|
111
|
+
process.stdin.setRawMode(true)
|
|
112
|
+
process.stdin.resume()
|
|
113
|
+
|
|
114
|
+
const finish = (error) => {
|
|
115
|
+
process.stdin.off('data', onData)
|
|
116
|
+
process.stdin.setRawMode(false)
|
|
117
|
+
process.stdin.pause()
|
|
118
|
+
process.stdout.write('\n')
|
|
119
|
+
if (error) reject(error)
|
|
120
|
+
else resolve(Buffer.from(value).toString('utf8'))
|
|
121
|
+
}
|
|
122
|
+
const onData = chunk => {
|
|
123
|
+
for (const byte of chunk) {
|
|
124
|
+
if (byte === 3) return finish(new Error('Setup cancelled.'))
|
|
125
|
+
if (byte === 13 || byte === 10) return finish()
|
|
126
|
+
if (byte === 8 || byte === 127) {
|
|
127
|
+
if (value.length) {
|
|
128
|
+
value.pop()
|
|
129
|
+
process.stdout.write('\b \b')
|
|
130
|
+
}
|
|
131
|
+
} else if (byte >= 32) {
|
|
132
|
+
value.push(byte)
|
|
133
|
+
process.stdout.write('•')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
process.stdin.on('data', onData)
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function runStep(step) {
|
|
142
|
+
process.stdout.write(`\n${step.label}…\n`)
|
|
143
|
+
const result = spawnSync(step.command, step.args, { stdio: 'inherit' })
|
|
144
|
+
if (result.error?.code === 'ENOENT') {
|
|
145
|
+
throw new Error(`${step.command} is not installed or is not on PATH.`)
|
|
146
|
+
}
|
|
147
|
+
if (result.error) throw result.error
|
|
148
|
+
if (result.status !== 0) {
|
|
149
|
+
if (step.optional) {
|
|
150
|
+
process.stderr.write(`TASKKA session is connected, but this optional step did not finish.\n`)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`${step.label} failed (exit ${result.status ?? 'unknown'}).`)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function runSetup(argv = process.argv.slice(3)) {
|
|
158
|
+
const { target, email: suppliedEmail } = parseSetupArgs(argv)
|
|
159
|
+
if (!process.stdin.isTTY) {
|
|
160
|
+
throw new Error('One-time setup needs an interactive terminal. Run it directly in your VPS SSH session.')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
process.stdout.write('\nTASKKA · ONE-TIME VPS SETUP\n')
|
|
164
|
+
process.stdout.write('Your password is used once to create this agent session. It is never saved.\n\n')
|
|
165
|
+
const email = suppliedEmail || await readVisible('TASKKA email: ')
|
|
166
|
+
if (!email || !email.includes('@')) throw new Error('Enter a valid TASKKA email address.')
|
|
167
|
+
let password = await readSecret('TASKKA password: ')
|
|
168
|
+
if (!password) throw new Error('Password cannot be blank.')
|
|
169
|
+
|
|
170
|
+
const { data, error } = await supa().auth.signInWithPassword({ email, password })
|
|
171
|
+
password = ''
|
|
172
|
+
if (error || !data?.session || !data?.user) {
|
|
173
|
+
throw new Error(`TASKKA sign-in failed: ${error?.message || 'no session returned'}`)
|
|
174
|
+
}
|
|
175
|
+
persistSession(data.session)
|
|
176
|
+
process.stdout.write(`Signed in as ${data.user.email || email}. Renewable session saved to ${SESSION_FILE}\n`)
|
|
177
|
+
|
|
178
|
+
for (const step of setupPlan(target)) runStep(step)
|
|
179
|
+
|
|
180
|
+
process.stdout.write(`\n✓ TASKKA is paired with ${target === 'hermes' ? 'Hermes' : 'OpenClaw'}.\n`)
|
|
181
|
+
process.stdout.write('You can leave it alone now; the MCP session renews itself.\n')
|
|
182
|
+
}
|
package/src/store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
2
|
// STATE ACCESS — read/modify/write the one JSONB blob, safely.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// TASKKA keeps everything in a single `user_states.state` row, upserted
|
|
5
5
|
// whole. With one writer that is fine. MCP adds a SECOND writer, and a plain
|
|
6
6
|
// upsert means last-write-wins — the web app saves, we save a moment later
|
|
7
7
|
// from a stale read, and everything it just did is silently gone.
|
|
@@ -80,13 +80,37 @@ export function supa() {
|
|
|
80
80
|
// So the pasted-into-config token pair is only ever the SEED: after the first
|
|
81
81
|
// refresh, the current pair lives in this file and the env values are stale by
|
|
82
82
|
// design. Delete the file to start over from the env seed.
|
|
83
|
-
const SESSION_FILE = path.join(os.homedir(), '.taskswipe-mcp-session.json')
|
|
83
|
+
export const SESSION_FILE = path.join(os.homedir(), '.taskswipe-mcp-session.json')
|
|
84
|
+
|
|
85
|
+
function sessionCandidates() {
|
|
86
|
+
const out = []
|
|
87
|
+
const seen = new Set()
|
|
88
|
+
const add = file => {
|
|
89
|
+
const p = String(file || '').trim()
|
|
90
|
+
if (!p || seen.has(p)) return
|
|
91
|
+
seen.add(p)
|
|
92
|
+
out.push(p)
|
|
93
|
+
}
|
|
94
|
+
add(process.env.TASKSWIPE_SESSION_FILE)
|
|
95
|
+
add(SESSION_FILE)
|
|
96
|
+
add(path.join(os.homedir(), 'taskswipe-mcp-session.json'))
|
|
97
|
+
add('/opt/data/.taskswipe-mcp-session.json')
|
|
98
|
+
add('/opt/data/taskswipe-mcp-session.json')
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
84
101
|
|
|
85
102
|
function loadSavedSession() {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
103
|
+
for (const file of sessionCandidates()) {
|
|
104
|
+
try {
|
|
105
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
106
|
+
if (j?.access_token && j?.refresh_token) return j
|
|
107
|
+
} catch { /* try the next path */ }
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function persistSession(session) {
|
|
113
|
+
saveSession(session)
|
|
90
114
|
}
|
|
91
115
|
|
|
92
116
|
function saveSession(session) {
|
|
@@ -185,22 +209,41 @@ export async function userId() {
|
|
|
185
209
|
return cachedUserId
|
|
186
210
|
}
|
|
187
211
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
212
|
+
const email = process.env.TASKSWIPE_EMAIL
|
|
213
|
+
const password = process.env.TASKSWIPE_PASSWORD
|
|
214
|
+
if (email && password) {
|
|
215
|
+
console.error(
|
|
216
|
+
'[taskswipe] Signing in with a stored password. Prefer TASKSWIPE_ACCESS_TOKEN — '
|
|
217
|
+
+ 'it expires on its own. See the README.',
|
|
218
|
+
)
|
|
219
|
+
const { data, error } = await s.auth.signInWithPassword({ email, password })
|
|
220
|
+
if (error || !data?.user) throw new Error(`Sign-in failed: ${error?.message || 'unknown error'}`)
|
|
221
|
+
if (data.session) saveSession(data.session)
|
|
222
|
+
cachedUserId = data.user.id
|
|
223
|
+
return cachedUserId
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
throw new Error(
|
|
227
|
+
'No TASKKA session for this process. Looked for '
|
|
228
|
+
+ sessionCandidates().join(', ')
|
|
229
|
+
+ ` (HOME=${os.homedir()}). Run \`npx -y taskswipe-mcp@latest setup hermes\` `
|
|
230
|
+
+ 'as the same user the Hermes gateway uses. Setup writes a session file — '
|
|
231
|
+
+ 'do not set TASKSWIPE_EMAIL. Or put TASKSWIPE_ACCESS_TOKEN and '
|
|
232
|
+
+ 'TASKSWIPE_REFRESH_TOKEN in the MCP env.',
|
|
195
233
|
)
|
|
234
|
+
}
|
|
196
235
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
236
|
+
/** Signed-in user for owner-gated tools (Callum OS). */
|
|
237
|
+
export async function currentUser() {
|
|
238
|
+
if (FAKE) return { email: process.env.CALLUM_OS_OWNER_EMAIL || 'c.dempsey@live.com.au' }
|
|
239
|
+
const scoped = requestCtx.getStore()?.supa
|
|
240
|
+
if (scoped) {
|
|
241
|
+
const { data } = await scoped.auth.getUser()
|
|
242
|
+
return data?.user || null
|
|
243
|
+
}
|
|
244
|
+
await userId()
|
|
245
|
+
const { data } = await supa().auth.getUser()
|
|
246
|
+
return data?.user || null
|
|
204
247
|
}
|
|
205
248
|
|
|
206
249
|
/** Current state plus the stamp a later write must match. */
|