huly-mcp-sdk 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.
Files changed (53) hide show
  1. package/.env.example +16 -0
  2. package/README.md +189 -0
  3. package/dist/connection.d.ts +5 -0
  4. package/dist/connection.js +111 -0
  5. package/dist/connection.js.map +1 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +25 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/schemas.d.ts +177 -0
  10. package/dist/schemas.js +78 -0
  11. package/dist/schemas.js.map +1 -0
  12. package/dist/server.d.ts +2 -0
  13. package/dist/server.js +48 -0
  14. package/dist/server.js.map +1 -0
  15. package/dist/tools/comments.d.ts +9 -0
  16. package/dist/tools/comments.js +19 -0
  17. package/dist/tools/comments.js.map +1 -0
  18. package/dist/tools/documents.d.ts +14 -0
  19. package/dist/tools/documents.js +27 -0
  20. package/dist/tools/documents.js.map +1 -0
  21. package/dist/tools/issues.d.ts +51 -0
  22. package/dist/tools/issues.js +179 -0
  23. package/dist/tools/issues.js.map +1 -0
  24. package/dist/tools/labels.d.ts +35 -0
  25. package/dist/tools/labels.js +150 -0
  26. package/dist/tools/labels.js.map +1 -0
  27. package/dist/tools/members.d.ts +6 -0
  28. package/dist/tools/members.js +17 -0
  29. package/dist/tools/members.js.map +1 -0
  30. package/dist/tools/milestones.d.ts +8 -0
  31. package/dist/tools/milestones.js +26 -0
  32. package/dist/tools/milestones.js.map +1 -0
  33. package/dist/tools/projects.d.ts +14 -0
  34. package/dist/tools/projects.js +33 -0
  35. package/dist/tools/projects.js.map +1 -0
  36. package/dist/tools/relations.d.ts +27 -0
  37. package/dist/tools/relations.js +103 -0
  38. package/dist/tools/relations.js.map +1 -0
  39. package/dist/tools/search.d.ts +9 -0
  40. package/dist/tools/search.js +32 -0
  41. package/dist/tools/search.js.map +1 -0
  42. package/dist/utils/errors.d.ts +11 -0
  43. package/dist/utils/errors.js +24 -0
  44. package/dist/utils/errors.js.map +1 -0
  45. package/dist/utils/format.d.ts +4 -0
  46. package/dist/utils/format.js +28 -0
  47. package/dist/utils/format.js.map +1 -0
  48. package/package.json +63 -0
  49. package/scripts/cleanup-issues.js +132 -0
  50. package/scripts/demo.js +193 -0
  51. package/scripts/import-csv.js +242 -0
  52. package/scripts/sample-tasks.csv +11 -0
  53. package/scripts/setup.js +103 -0
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Live demo of huly-mcp-sdk tools against a real Huly workspace.
4
+ * Runs through: list projects → create issue → add comment → add label →
5
+ * update issue → add relation → get issue → delete issue
6
+ */
7
+
8
+ 'use strict'
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+
13
+ // Load .env
14
+ const envPath = path.join(__dirname, '..', '.env')
15
+ if (fs.existsSync(envPath)) {
16
+ for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
17
+ const m = line.match(/^([^#=\s]+)\s*=\s*(.*)$/)
18
+ if (m) process.env[m[1]] = m[2].trim()
19
+ }
20
+ }
21
+
22
+ const distDir = path.join(__dirname, '..', 'dist')
23
+ const { getConnection, closeConnection } = require(path.join(distDir, 'connection'))
24
+ const tracker = require('@hcengineering/tracker')
25
+ const task = require('@hcengineering/task')
26
+ const tags = require('@hcengineering/tags')
27
+ const { generateId, SortingOrder } = require('@hcengineering/core')
28
+ const { makeRank } = require('@hcengineering/rank')
29
+
30
+ const GREEN = '\x1b[32m'
31
+ const CYAN = '\x1b[36m'
32
+ const YELLOW = '\x1b[33m'
33
+ const BOLD = '\x1b[1m'
34
+ const RESET = '\x1b[0m'
35
+
36
+ function header (text) {
37
+ console.log(`\n${BOLD}${CYAN}━━━ ${text} ━━━${RESET}`)
38
+ }
39
+ function ok (text) { console.log(`${GREEN}✅ ${text}${RESET}`) }
40
+ function info (text) { console.log(`${YELLOW} ${text}${RESET}`) }
41
+ function print (text) { console.log(` ${text}`) }
42
+
43
+ async function main () {
44
+ console.log(`\n${BOLD}🚀 huly-mcp-sdk live demo${RESET}`)
45
+ console.log(` Workspace: ${process.env.HULY_WORKSPACE}\n`)
46
+
47
+ const client = await getConnection()
48
+ ok('Connected to Huly')
49
+
50
+ // ── 1. List projects ──────────────────────────────────────────────────────
51
+ header('1 / 8 — list_projects')
52
+ const projects = await client.findAll(tracker.default.class.Project, {})
53
+ ok(`Found ${projects.length} project(s)`)
54
+ for (const p of projects) {
55
+ print(`• ${BOLD}${p.identifier}${RESET} ${p.name}`)
56
+ }
57
+
58
+ // Pick first project for the rest of the demo
59
+ const project = projects[0]
60
+ if (project == null) { console.error('No projects found — aborting.'); return }
61
+ info(`Using project: ${project.identifier}`)
62
+
63
+ // ── 2. List issues (top 5) ────────────────────────────────────────────────
64
+ header('2 / 8 — list_issues')
65
+ const issues = await client.findAll(
66
+ tracker.default.class.Issue,
67
+ { space: project._id },
68
+ { limit: 5, sort: { modifiedOn: SortingOrder.Descending } }
69
+ )
70
+ const statuses = await client.findAll(tracker.default.class.IssueStatus, {})
71
+ const statusMap = new Map(statuses.map(s => [s._id, s.name]))
72
+ ok(`Showing 5 most-recent issues in ${project.identifier}`)
73
+ for (const i of issues) {
74
+ print(`• ${BOLD}${i.identifier}${RESET} [${statusMap.get(i.status) ?? '?'}] ${i.title}`)
75
+ }
76
+
77
+ // ── 3. Create issue ───────────────────────────────────────────────────────
78
+ header('3 / 8 — create_issue')
79
+ const incResult = await client.updateDoc(
80
+ tracker.default.class.Project, project.space, project._id,
81
+ { $inc: { sequence: 1 } }, true
82
+ )
83
+ const issueNumber = incResult?.object?.sequence ?? project.sequence + 1
84
+ const identifier = `${project.identifier}-${issueNumber}`
85
+
86
+ const defaultStatus = project.defaultIssueStatus != null
87
+ ? (statuses.find(s => s._id === project.defaultIssueStatus) ?? statuses[0])
88
+ : statuses[0]
89
+ const kind = await client.findOne(task.default.class.TaskType,
90
+ project.type != null ? { parent: project.type } : {})
91
+ const lastIssue = await client.findOne(
92
+ tracker.default.class.Issue, { space: project._id },
93
+ { sort: { rank: SortingOrder.Descending } }
94
+ )
95
+ const rank = makeRank(lastIssue?.rank, undefined)
96
+
97
+ const issueId = generateId()
98
+ await client.addCollection(
99
+ tracker.default.class.Issue, project._id,
100
+ tracker.default.ids.NoParent, tracker.default.class.Issue, 'subIssues',
101
+ {
102
+ title: '🧪 huly-mcp-sdk demo issue',
103
+ description: null,
104
+ status: defaultStatus._id,
105
+ priority: 2, // High
106
+ number: issueNumber, identifier, rank,
107
+ kind: kind._id,
108
+ comments: 0, subIssues: 0,
109
+ dueDate: null, assignee: null, component: null, milestone: null,
110
+ parents: [], remainingTime: 0, estimation: 0,
111
+ reportedTime: 0, reports: 0, childInfo: [], relations: []
112
+ },
113
+ issueId
114
+ )
115
+ ok(`Created ${BOLD}${identifier}${RESET}: 🧪 huly-mcp-sdk demo issue`)
116
+
117
+ // ── 4. Add comment ────────────────────────────────────────────────────────
118
+ header('4 / 8 — add_comment')
119
+ const chunter = require('@hcengineering/chunter')
120
+ await client.addCollection(
121
+ chunter.default.class.ChatMessage, project._id,
122
+ issueId, tracker.default.class.Issue, 'comments',
123
+ { message: 'This issue was created by the **huly-mcp-sdk** demo script 🎉', attachments: 0 }
124
+ )
125
+ ok(`Added comment to ${identifier}`)
126
+
127
+ // ── 5. Add label ──────────────────────────────────────────────────────────
128
+ header('5 / 8 — add_label')
129
+ const core = require('@hcengineering/core')
130
+ let label = await client.findOne(tags.default.class.TagElement, {
131
+ targetClass: tracker.default.class.Issue, title: 'demo'
132
+ })
133
+ if (label == null) {
134
+ const cat = await client.findOne(tags.default.class.TagCategory, {
135
+ targetClass: tracker.default.class.Issue
136
+ })
137
+ const labelId = generateId()
138
+ await client.createDoc(
139
+ tags.default.class.TagElement, core.default.space.Workspace,
140
+ {
141
+ title: 'demo', targetClass: tracker.default.class.Issue,
142
+ description: '', color: 0x6366f1,
143
+ category: cat?._id ?? tags.default.category.NoCategory, refCount: 0
144
+ }, labelId
145
+ )
146
+ label = await client.findOne(tags.default.class.TagElement, { _id: labelId })
147
+ info('Auto-created label "demo" (color: #6366f1)')
148
+ }
149
+ const issue = await client.findOne(tracker.default.class.Issue, { identifier })
150
+ await client.addCollection(
151
+ tags.default.class.TagReference, issue.space, issue._id,
152
+ tracker.default.class.Issue, 'labels',
153
+ { tag: label._id, title: label.title, color: label.color }
154
+ )
155
+ ok(`Labeled ${identifier} with "demo"`)
156
+
157
+ // ── 6. Update issue ───────────────────────────────────────────────────────
158
+ header('6 / 8 — update_issue')
159
+ const inProgress = statuses.find(s => s.name.toLowerCase().includes('progress')) ?? statuses[1] ?? statuses[0]
160
+ await client.updateDoc(
161
+ tracker.default.class.Issue, issue.space, issue._id,
162
+ { status: inProgress._id, priority: 1 /* Urgent */ }
163
+ )
164
+ ok(`Updated ${identifier} → status: "${inProgress.name}", priority: Urgent`)
165
+
166
+ // ── 7. Get issue (full details) ───────────────────────────────────────────
167
+ header('7 / 8 — get_issue')
168
+ const fresh = await client.findOne(tracker.default.class.Issue, { identifier })
169
+ const freshStatus = statusMap.get(fresh.status) ?? 'Unknown'
170
+ const PRIORITY = ['No priority', 'Urgent', 'High', 'Medium', 'Low']
171
+ ok(`Full details for ${identifier}`)
172
+ print(` Title: ${fresh.title}`)
173
+ print(` Status: ${freshStatus}`)
174
+ print(` Priority: ${PRIORITY[fresh.priority] ?? fresh.priority}`)
175
+ print(` Comments: ${fresh.comments}`)
176
+
177
+ // ── 8. Delete issue (cleanup) ─────────────────────────────────────────────
178
+ header('8 / 8 — delete_issue (cleanup)')
179
+ await client.removeCollection(
180
+ tracker.default.class.Issue, fresh.space, fresh._id,
181
+ fresh.attachedTo, fresh.attachedToClass, fresh.collection
182
+ )
183
+ ok(`Deleted ${identifier} — workspace is clean`)
184
+
185
+ await closeConnection()
186
+
187
+ console.log(`\n${BOLD}${GREEN}🎉 Demo complete! All 8 tools exercised successfully.${RESET}\n`)
188
+ }
189
+
190
+ main().catch(err => {
191
+ console.error('\n❌ Demo failed:', err.message)
192
+ process.exit(1)
193
+ })
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Bulk-import issues from a CSV file into a Huly tracker project.
4
+ *
5
+ * Usage:
6
+ * node scripts/import-csv.js <csv-file> <project-identifier>
7
+ *
8
+ * Example:
9
+ * node scripts/import-csv.js tasks.csv PROJ
10
+ *
11
+ * CSV format (first row = headers):
12
+ * title,priority,status,dueDate
13
+ * Fix login bug,High,In Progress,2025-04-01
14
+ * Add dark mode,Medium,,
15
+ *
16
+ * Required columns:
17
+ * title — issue title (required)
18
+ *
19
+ * Optional columns:
20
+ * priority — Urgent | High | Medium | Low | No priority (default: No priority)
21
+ * status — name of an existing status in the project (default: project default)
22
+ * dueDate — YYYY-MM-DD (default: none)
23
+ *
24
+ * Auth (set ONE option as env vars):
25
+ * Option A (SSO/Google/GitHub):
26
+ * HULY_TOKEN=eyJ... — token from DevTools → Application → IndexedDB database name
27
+ *
28
+ * Option B (email + password):
29
+ * HULY_EMAIL=you@example.com
30
+ * HULY_PASSWORD=yourpassword
31
+ *
32
+ * Always required:
33
+ * HULY_WORKSPACE=your-workspace-slug
34
+ */
35
+
36
+ 'use strict'
37
+
38
+ const fs = require('fs')
39
+ const path = require('path')
40
+
41
+ // ── Load compiled huly-mcp modules ──────────────────────────────────────────
42
+ const distDir = path.join(__dirname, '..', 'dist')
43
+ if (!fs.existsSync(path.join(distDir, 'connection.js'))) {
44
+ console.error('❌ dist/ not found. Run `npm run build` first.')
45
+ process.exit(1)
46
+ }
47
+ const { getConnection, closeConnection } = require(path.join(distDir, 'connection'))
48
+ const tracker = require('@hcengineering/tracker')
49
+ const task = require('@hcengineering/task')
50
+ const { SortingOrder, generateId } = require('@hcengineering/core')
51
+ const { makeRank } = require('@hcengineering/rank')
52
+
53
+ // ── Parse CLI args ────────────────────────────────────────────────────────────
54
+ const [,, csvFile, projectIdentifier] = process.argv
55
+ if (!csvFile || !projectIdentifier) {
56
+ console.error('Usage: node scripts/import-csv.js <csv-file> <project-identifier>')
57
+ process.exit(1)
58
+ }
59
+ if (!fs.existsSync(csvFile)) {
60
+ console.error(`❌ File not found: ${csvFile}`)
61
+ process.exit(1)
62
+ }
63
+
64
+ // ── CSV parser (no dependencies) ─────────────────────────────────────────────
65
+ function parseCsv (text) {
66
+ const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(Boolean)
67
+ if (lines.length < 2) return []
68
+
69
+ const headers = lines[0].split(',').map(h => h.trim().toLowerCase())
70
+ return lines.slice(1).map(line => {
71
+ // Handle quoted fields
72
+ const fields = []
73
+ let current = ''
74
+ let inQuotes = false
75
+ for (const ch of line) {
76
+ if (ch === '"') { inQuotes = !inQuotes }
77
+ else if (ch === ',' && !inQuotes) { fields.push(current.trim()); current = '' }
78
+ else { current += ch }
79
+ }
80
+ fields.push(current.trim())
81
+ return Object.fromEntries(headers.map((h, i) => [h, fields[i] ?? '']))
82
+ })
83
+ }
84
+
85
+ // ── Priority mapping ──────────────────────────────────────────────────────────
86
+ const PRIORITY_MAP = {
87
+ 'urgent': 1, // IssuePriority.Urgent
88
+ 'high': 2, // IssuePriority.High
89
+ 'medium': 3, // IssuePriority.Medium
90
+ 'low': 4, // IssuePriority.Low
91
+ 'no priority': 0,
92
+ '': 0
93
+ }
94
+
95
+ function parsePriority (str) {
96
+ const key = (str || '').toLowerCase().trim()
97
+ return PRIORITY_MAP[key] ?? 0
98
+ }
99
+
100
+ // ── Main import ───────────────────────────────────────────────────────────────
101
+ async function main () {
102
+ console.log(`\n📋 Huly CSV Importer`)
103
+ console.log(` File: ${csvFile}`)
104
+ console.log(` Project: ${projectIdentifier}`)
105
+ console.log(` Workspace: ${process.env.HULY_WORKSPACE}\n`)
106
+
107
+ // Parse CSV
108
+ const rows = parseCsv(fs.readFileSync(csvFile, 'utf8'))
109
+ const validRows = rows.filter(r => r.title && r.title.trim())
110
+ console.log(`📂 Found ${rows.length} rows, ${validRows.length} with titles.\n`)
111
+
112
+ if (validRows.length === 0) {
113
+ console.error('❌ No valid rows found. Make sure CSV has a "title" column.')
114
+ process.exit(1)
115
+ }
116
+
117
+ // Connect to Huly
118
+ console.log('🔌 Connecting to Huly...')
119
+ const client = await getConnection()
120
+ console.log('✅ Connected.\n')
121
+
122
+ // Find project
123
+ const project = await client.findOne(tracker.default.class.Project, { identifier: projectIdentifier })
124
+ if (project == null) {
125
+ console.error(`❌ Project '${projectIdentifier}' not found.`)
126
+ await closeConnection()
127
+ process.exit(1)
128
+ }
129
+
130
+ // Load statuses — Huly stores them in project space OR global model space
131
+ let statuses = await client.findAll(tracker.default.class.IssueStatus, { space: project._id })
132
+ if (statuses.length === 0) {
133
+ // Fall back to global model-level statuses (used by default project types)
134
+ statuses = await client.findAll(tracker.default.class.IssueStatus, {})
135
+ }
136
+ const statusMap = new Map(statuses.map(s => [s.name.toLowerCase(), s]))
137
+ const defaultStatus = project.defaultIssueStatus != null
138
+ ? (statuses.find(s => s._id === project.defaultIssueStatus) ?? statuses[0])
139
+ : statuses[0]
140
+
141
+ // Find TaskType
142
+ const kind = await client.findOne(task.default.class.TaskType,
143
+ project.type != null ? { parent: project.type } : {})
144
+ if (kind == null) {
145
+ console.error('❌ Could not find a TaskType for this project.')
146
+ await closeConnection()
147
+ process.exit(1)
148
+ }
149
+
150
+ console.log(`📁 Project: ${project.name}`)
151
+ console.log(`📊 Available statuses: ${statuses.map(s => s.name).join(', ')}`)
152
+ console.log(`🎯 Default status: ${defaultStatus.name}\n`)
153
+
154
+ // Import rows
155
+ let created = 0
156
+ let failed = 0
157
+
158
+ for (let i = 0; i < validRows.length; i++) {
159
+ const row = validRows[i]
160
+ const num = i + 1
161
+
162
+ process.stdout.write(`[${num}/${validRows.length}] Creating: "${row.title.slice(0, 60)}"... `)
163
+
164
+ try {
165
+ // Increment sequence
166
+ const incResult = await client.updateDoc(
167
+ tracker.default.class.Project,
168
+ project.space,
169
+ project._id,
170
+ { $inc: { sequence: 1 } },
171
+ true
172
+ )
173
+ const issueNumber = incResult?.object?.sequence ?? project.sequence + 1
174
+ const identifier = `${project.identifier}-${issueNumber}`
175
+
176
+ // Resolve status
177
+ const statusName = (row.status || '').toLowerCase().trim()
178
+ const status = statusName ? (statusMap.get(statusName) ?? defaultStatus) : defaultStatus
179
+
180
+ // Compute rank
181
+ const lastIssue = await client.findOne(
182
+ tracker.default.class.Issue,
183
+ { space: project._id },
184
+ { sort: { rank: SortingOrder.Descending } }
185
+ )
186
+ const rank = makeRank(lastIssue?.rank, undefined)
187
+
188
+ // Create issue
189
+ const issueId = generateId()
190
+ await client.addCollection(
191
+ tracker.default.class.Issue,
192
+ project._id,
193
+ tracker.default.ids.NoParent,
194
+ tracker.default.class.Issue,
195
+ 'subIssues',
196
+ {
197
+ title: row.title.trim(),
198
+ description: null,
199
+ status: status._id,
200
+ priority: parsePriority(row.priority),
201
+ number: issueNumber,
202
+ identifier,
203
+ rank,
204
+ kind: kind._id,
205
+ comments: 0,
206
+ subIssues: 0,
207
+ dueDate: row.duedate || row.dueDate
208
+ ? new Date(row.duedate || row.dueDate).getTime()
209
+ : null,
210
+ assignee: null,
211
+ component: null,
212
+ milestone: null,
213
+ parents: [],
214
+ remainingTime: 0,
215
+ estimation: 0,
216
+ reportedTime: 0,
217
+ reports: 0,
218
+ childInfo: [],
219
+ relations: []
220
+ },
221
+ issueId
222
+ )
223
+
224
+ console.log(`✅ ${identifier}`)
225
+ created++
226
+ } catch (err) {
227
+ console.log(`❌ ERROR: ${err.message}`)
228
+ failed++
229
+ }
230
+ }
231
+
232
+ await closeConnection()
233
+
234
+ console.log(`\n${'─'.repeat(50)}`)
235
+ console.log(`✅ Created: ${created} ❌ Failed: ${failed}`)
236
+ console.log(`${'─'.repeat(50)}\n`)
237
+ }
238
+
239
+ main().catch(err => {
240
+ console.error('\n❌ Fatal error:', err.message)
241
+ process.exit(1)
242
+ })
@@ -0,0 +1,11 @@
1
+ title,priority,status,dueDate
2
+ Fix login timeout bug,High,In Progress,2025-04-01
3
+ Add dark mode support,Medium,,
4
+ Update onboarding flow,Low,,2025-05-15
5
+ Write API documentation,Medium,,
6
+ Fix mobile layout issues,High,,
7
+ Performance optimization for dashboard,Urgent,,2025-03-31
8
+ Add two-factor authentication,High,,
9
+ Update privacy policy page,Low,,
10
+ Fix broken image uploads,High,In Progress,
11
+ Improve search functionality,Medium,,
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-time setup: authenticate via OTP and save your token to .env
4
+ *
5
+ * Usage:
6
+ * node scripts/setup.js
7
+ *
8
+ * What it does:
9
+ * 1. Sends a one-time code to your email
10
+ * 2. You paste the code
11
+ * 3. Saves HULY_TOKEN + HULY_WORKSPACE to .env
12
+ *
13
+ * After setup, the MCP server and import script work without any manual token steps.
14
+ */
15
+
16
+ 'use strict'
17
+
18
+ const fs = require('fs')
19
+ const path = require('path')
20
+ const readline = require('readline')
21
+
22
+ const { getClient } = require('@hcengineering/account-client')
23
+
24
+ const envFile = path.join(__dirname, '..', '.env')
25
+ const accountsUrl = process.env.HULY_ACCOUNTS_URL ?? 'https://account.huly.app'
26
+
27
+ function ask (question) {
28
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
29
+ return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()) }))
30
+ }
31
+
32
+ function writeEnv (token, workspace, accountsUrl) {
33
+ const lines = [
34
+ `HULY_WORKSPACE=${workspace}`,
35
+ `HULY_TOKEN=${token}`,
36
+ accountsUrl !== 'https://account.huly.app' ? `HULY_ACCOUNTS_URL=${accountsUrl}` : null
37
+ ].filter(Boolean)
38
+
39
+ // Merge with existing .env (don't overwrite unrelated vars)
40
+ let existing = ''
41
+ if (fs.existsSync(envFile)) {
42
+ existing = fs.readFileSync(envFile, 'utf8')
43
+ }
44
+
45
+ const keep = existing.split('\n').filter(line => {
46
+ const key = line.split('=')[0]
47
+ return !['HULY_TOKEN', 'HULY_WORKSPACE', 'HULY_EMAIL', 'HULY_PASSWORD', 'HULY_ACCOUNTS_URL'].includes(key)
48
+ })
49
+
50
+ fs.writeFileSync(envFile, [...keep.filter(Boolean), ...lines].join('\n') + '\n')
51
+ }
52
+
53
+ async function main () {
54
+ console.log('\n🔧 Huly MCP Setup\n')
55
+
56
+ const email = await ask(' Email address (the one you use for huly.app): ')
57
+ if (!email.includes('@')) { console.error('Invalid email.'); process.exit(1) }
58
+
59
+ const workspace = await ask(' Workspace slug (e.g. "medics" from huly.app/medics): ')
60
+ if (!workspace) { console.error('Workspace is required.'); process.exit(1) }
61
+
62
+ console.log(`\n Sending OTP to ${email}...`)
63
+ const unauthClient = getClient(accountsUrl)
64
+ await unauthClient.loginOtp(email)
65
+ console.log(' ✅ Code sent!\n')
66
+
67
+ const code = await ask(' Enter the 6-digit code from your email: ')
68
+
69
+ console.log('\n Validating...')
70
+ const loginInfo = await unauthClient.validateOtp(email, code.trim())
71
+
72
+ if (!loginInfo?.token) {
73
+ console.error(' ❌ Invalid or expired code. Run setup again.')
74
+ process.exit(1)
75
+ }
76
+
77
+ // Verify workspace is accessible
78
+ const authedClient = getClient(accountsUrl, loginInfo.token)
79
+ const wsInfo = await authedClient.selectWorkspace(workspace, 'external').catch(e => {
80
+ console.error(` ❌ Could not access workspace '${workspace}': ${e.message}`)
81
+ process.exit(1)
82
+ })
83
+
84
+ if (!wsInfo?.endpoint) {
85
+ console.error(` ❌ Workspace '${workspace}' not found or not accessible.`)
86
+ process.exit(1)
87
+ }
88
+
89
+ // Save token to .env
90
+ writeEnv(loginInfo.token, workspace, accountsUrl)
91
+
92
+ console.log(`\n ✅ Setup complete!`)
93
+ console.log(` Workspace : ${workspace}`)
94
+ console.log(` Endpoint : ${wsInfo.endpoint}`)
95
+ console.log(` Token saved to .env\n`)
96
+ console.log(' You can now run:')
97
+ console.log(' node scripts/import-csv.js your-tasks.csv PROJ\n')
98
+ }
99
+
100
+ main().catch(err => {
101
+ console.error('\n❌ Setup failed:', err.message)
102
+ process.exit(1)
103
+ })