@syphin/cli 0.1.0 → 0.3.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/package.json +1 -1
- package/src/commands/init.js +120 -43
- package/src/commands/login.js +103 -16
- package/src/commands/switch.js +98 -0
- package/src/index.js +2 -0
- package/src/lib/api.js +22 -0
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -3,21 +3,20 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import ora from 'ora'
|
|
6
|
+
import prompts from 'prompts'
|
|
6
7
|
import { basename } from 'path'
|
|
7
8
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
|
8
9
|
import { loadToken, saveProjectConfig } from '../lib/config.js'
|
|
9
10
|
import { createApiClient } from '../lib/api.js'
|
|
10
|
-
import { success, fail, info, teal } from '../lib/output.js'
|
|
11
|
+
import { success, fail, info, teal, dim } from '../lib/output.js'
|
|
11
12
|
|
|
12
13
|
function detectProjectName() {
|
|
13
|
-
// Try package.json
|
|
14
14
|
if (existsSync('package.json')) {
|
|
15
15
|
try {
|
|
16
16
|
const pkg = JSON.parse(readFileSync('package.json', 'utf-8'))
|
|
17
17
|
if (pkg.name) return pkg.name.replace(/^@[^/]+\//, '')
|
|
18
18
|
} catch {}
|
|
19
19
|
}
|
|
20
|
-
// Fall back to directory name
|
|
21
20
|
return basename(process.cwd())
|
|
22
21
|
}
|
|
23
22
|
|
|
@@ -33,15 +32,96 @@ function updateMcpJson() {
|
|
|
33
32
|
|
|
34
33
|
if (!mcp.mcpServers) mcp.mcpServers = {}
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
type: 'stdio'
|
|
40
|
-
|
|
35
|
+
const isWindows = process.platform === 'win32'
|
|
36
|
+
|
|
37
|
+
mcp.mcpServers.syphin = isWindows
|
|
38
|
+
? { command: 'cmd', args: ['/c', 'npx', '@syphin/mcp'], type: 'stdio' }
|
|
39
|
+
: { command: 'npx', args: ['@syphin/mcp'], type: 'stdio' }
|
|
41
40
|
|
|
42
41
|
writeFileSync(mcpPath, JSON.stringify(mcp, null, 2))
|
|
43
42
|
}
|
|
44
43
|
|
|
44
|
+
async function pickProject(api, skipPrompts) {
|
|
45
|
+
const spinner = ora('Fetching your projects...').start()
|
|
46
|
+
let projects
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
projects = await api.listProjects()
|
|
50
|
+
spinner.stop()
|
|
51
|
+
} catch (err) {
|
|
52
|
+
spinner.fail('Failed to fetch projects')
|
|
53
|
+
fail(err.message)
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (projects.length === 0) {
|
|
58
|
+
console.log('')
|
|
59
|
+
info('No projects found. Creating one from this directory.')
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (skipPrompts) {
|
|
64
|
+
// --yes: match by directory name
|
|
65
|
+
const detected = detectProjectName().toLowerCase().replace(/[^a-z0-9-]/g, '-')
|
|
66
|
+
const match = projects.find(p => p.slug === detected)
|
|
67
|
+
if (match) return match
|
|
68
|
+
info(`No project matching "${detected}" — creating it.`)
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log('')
|
|
73
|
+
const choices = [
|
|
74
|
+
...projects.map(p => ({
|
|
75
|
+
title: `${p.name} ${dim(`(${p.slug})`)}`,
|
|
76
|
+
value: p,
|
|
77
|
+
})),
|
|
78
|
+
{ title: teal('+ Create new project'), value: null },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const { project } = await prompts({
|
|
82
|
+
type: 'select',
|
|
83
|
+
name: 'project',
|
|
84
|
+
message: 'Connect to a project',
|
|
85
|
+
choices,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
if (project === undefined) {
|
|
89
|
+
// User cancelled
|
|
90
|
+
process.exit(0)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return project
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function createNewProject(api, skipPrompts) {
|
|
97
|
+
const detected = detectProjectName()
|
|
98
|
+
let name = detected
|
|
99
|
+
|
|
100
|
+
if (!skipPrompts) {
|
|
101
|
+
const response = await prompts({
|
|
102
|
+
type: 'text',
|
|
103
|
+
name: 'name',
|
|
104
|
+
message: 'Project name',
|
|
105
|
+
initial: detected,
|
|
106
|
+
})
|
|
107
|
+
if (!response.name) process.exit(0)
|
|
108
|
+
name = response.name
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, '-')
|
|
112
|
+
const spinner = ora('Creating project...').start()
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const project = await api.createProject(name, slug)
|
|
116
|
+
spinner.succeed(`Created ${teal(name)}`)
|
|
117
|
+
return project
|
|
118
|
+
} catch (err) {
|
|
119
|
+
spinner.fail('Failed to create project')
|
|
120
|
+
fail(err.message)
|
|
121
|
+
process.exit(1)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
45
125
|
export function registerInit(program) {
|
|
46
126
|
program
|
|
47
127
|
.command('init')
|
|
@@ -55,49 +135,46 @@ export function registerInit(program) {
|
|
|
55
135
|
process.exit(1)
|
|
56
136
|
}
|
|
57
137
|
|
|
58
|
-
const
|
|
59
|
-
const slug = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-')
|
|
60
|
-
const env = opts.env
|
|
61
|
-
|
|
62
|
-
info(`Project: ${teal(projectName)} (${slug})`)
|
|
63
|
-
info(`Environment: ${env}`)
|
|
64
|
-
|
|
65
|
-
const spinner = ora('Connecting to Syphin...').start()
|
|
138
|
+
const api = createApiClient(token)
|
|
66
139
|
|
|
140
|
+
// Verify auth
|
|
141
|
+
const spinner = ora('Authenticating...').start()
|
|
67
142
|
try {
|
|
68
|
-
const api = createApiClient(token)
|
|
69
|
-
|
|
70
|
-
// Verify auth first
|
|
71
143
|
await api.verify()
|
|
144
|
+
spinner.stop()
|
|
145
|
+
} catch (err) {
|
|
146
|
+
spinner.fail('Authentication failed')
|
|
147
|
+
fail(err.message)
|
|
148
|
+
process.exit(1)
|
|
149
|
+
}
|
|
72
150
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// Update .mcp.json
|
|
80
|
-
updateMcpJson()
|
|
151
|
+
// Pick or create project
|
|
152
|
+
let project = await pickProject(api, opts.yes)
|
|
153
|
+
if (!project) {
|
|
154
|
+
project = await createNewProject(api, opts.yes)
|
|
155
|
+
}
|
|
81
156
|
|
|
82
|
-
|
|
157
|
+
const env = opts.env
|
|
83
158
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
info(`Total always-on tokens: ${teal('~' + manifest.totalTokens)}`)
|
|
91
|
-
} catch {
|
|
92
|
-
info('No manifest yet — the project may need skills bound to it.')
|
|
93
|
-
}
|
|
159
|
+
// Save config
|
|
160
|
+
saveProjectConfig({
|
|
161
|
+
projectSlug: project.slug,
|
|
162
|
+
environment: env,
|
|
163
|
+
})
|
|
164
|
+
updateMcpJson()
|
|
94
165
|
|
|
166
|
+
// Show manifest summary
|
|
167
|
+
try {
|
|
168
|
+
const manifest = await api.getManifest(project.slug, env)
|
|
95
169
|
console.log('')
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
170
|
+
info(`Always-on skills: ${teal(manifest.alwaysOn.length.toString())}`)
|
|
171
|
+
info(`Available skills: ${teal(manifest.available.length.toString())}`)
|
|
172
|
+
info(`Total always-on tokens: ${teal('~' + manifest.totalTokens)}`)
|
|
173
|
+
} catch {
|
|
174
|
+
info('No manifest yet — bind skills to this project in the dashboard.')
|
|
101
175
|
}
|
|
176
|
+
|
|
177
|
+
console.log('')
|
|
178
|
+
success(`Connected to ${teal(project.name || project.slug)} (${env}). Restart Claude Code to activate Syphin.`)
|
|
102
179
|
})
|
|
103
180
|
}
|
package/src/commands/login.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* syphin login [--token <t>] [--force]
|
|
3
|
+
*
|
|
4
|
+
* Without --token: starts OAuth device flow (opens browser).
|
|
5
|
+
* With --token: direct token authentication.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
import ora from 'ora'
|
|
9
|
+
import open from 'open'
|
|
6
10
|
import { saveToken, loadToken } from '../lib/config.js'
|
|
7
11
|
import { createApiClient } from '../lib/api.js'
|
|
8
|
-
import { success, fail, info, teal } from '../lib/output.js'
|
|
12
|
+
import { success, fail, info, teal, dim } from '../lib/output.js'
|
|
13
|
+
|
|
14
|
+
const POLL_INTERVAL = 5000 // 5 seconds
|
|
15
|
+
const MAX_POLLS = 120 // 10 minutes / 5s
|
|
9
16
|
|
|
10
17
|
export function registerLogin(program) {
|
|
11
18
|
program
|
|
@@ -20,30 +27,110 @@ export function registerLogin(program) {
|
|
|
20
27
|
return
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
info('Get a token from the Syphin dashboard at https://app.syphin.dev/settings/tokens')
|
|
27
|
-
process.exit(1)
|
|
30
|
+
// Direct token mode
|
|
31
|
+
if (opts.token) {
|
|
32
|
+
return directLogin(opts.token)
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
// Device flow
|
|
36
|
+
return deviceFlowLogin()
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function directLogin(token) {
|
|
41
|
+
const spinner = ora('Verifying token...').start()
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const api = createApiClient(token)
|
|
45
|
+
const result = await api.verify()
|
|
46
|
+
|
|
47
|
+
saveToken(token)
|
|
48
|
+
spinner.succeed('Authenticated successfully!')
|
|
49
|
+
|
|
50
|
+
console.log('')
|
|
51
|
+
info(`Organization: ${teal(result.orgName)} (${result.orgSlug})`)
|
|
52
|
+
info(`Plan: ${result.orgPlan}`)
|
|
53
|
+
console.log('')
|
|
54
|
+
} catch (err) {
|
|
55
|
+
spinner.fail('Authentication failed')
|
|
56
|
+
fail(err.message)
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function deviceFlowLogin() {
|
|
62
|
+
const api = createApiClient(null)
|
|
63
|
+
let deviceData
|
|
64
|
+
|
|
65
|
+
const spinner = ora('Starting authentication...').start()
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
deviceData = await api.requestDeviceCode()
|
|
69
|
+
spinner.stop()
|
|
70
|
+
} catch (err) {
|
|
71
|
+
spinner.fail('Failed to start authentication')
|
|
72
|
+
fail(err.message)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
31
75
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
76
|
+
// Display the code
|
|
77
|
+
console.log('')
|
|
78
|
+
console.log(` Open this URL in your browser:`)
|
|
79
|
+
console.log('')
|
|
80
|
+
console.log(` ${teal(deviceData.verificationUrl)}`)
|
|
81
|
+
console.log('')
|
|
82
|
+
console.log(` And enter this code:`)
|
|
83
|
+
console.log('')
|
|
84
|
+
console.log(` ${teal(deviceData.userCode)}`)
|
|
85
|
+
console.log('')
|
|
86
|
+
info(dim('Code expires in 10 minutes'))
|
|
87
|
+
console.log('')
|
|
35
88
|
|
|
36
|
-
|
|
37
|
-
|
|
89
|
+
// Try to open the browser
|
|
90
|
+
try {
|
|
91
|
+
await open(deviceData.verificationUrl)
|
|
92
|
+
} catch {
|
|
93
|
+
// Browser open failed — user can manually navigate
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Poll for approval
|
|
97
|
+
const pollSpinner = ora('Waiting for browser authorization...').start()
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < MAX_POLLS; i++) {
|
|
100
|
+
await sleep(POLL_INTERVAL)
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const result = await api.pollDeviceToken(deviceData.deviceCode)
|
|
104
|
+
|
|
105
|
+
if (result.status === 'pending') {
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (result.status === 'approved') {
|
|
110
|
+
saveToken(result.token)
|
|
111
|
+
pollSpinner.succeed('Authenticated successfully!')
|
|
38
112
|
|
|
39
113
|
console.log('')
|
|
40
114
|
info(`Organization: ${teal(result.orgName)} (${result.orgSlug})`)
|
|
41
115
|
info(`Plan: ${result.orgPlan}`)
|
|
42
116
|
console.log('')
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err.message.includes('expired') || err.message.includes('EXPIRED')) {
|
|
121
|
+
pollSpinner.fail('Code expired')
|
|
122
|
+
fail('Run `syphin login` to try again.')
|
|
46
123
|
process.exit(1)
|
|
47
124
|
}
|
|
48
|
-
|
|
125
|
+
// Other errors — keep polling
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pollSpinner.fail('Timed out waiting for authorization')
|
|
130
|
+
fail('Run `syphin login` to try again.')
|
|
131
|
+
process.exit(1)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sleep(ms) {
|
|
135
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
49
136
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syphin switch
|
|
3
|
+
*
|
|
4
|
+
* Quick project switcher for an already-initialized directory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import ora from 'ora'
|
|
8
|
+
import prompts from 'prompts'
|
|
9
|
+
import { loadToken, loadProjectConfig, saveProjectConfig } from '../lib/config.js'
|
|
10
|
+
import { createApiClient } from '../lib/api.js'
|
|
11
|
+
import { success, fail, info, teal, dim } from '../lib/output.js'
|
|
12
|
+
|
|
13
|
+
export function registerSwitch(program) {
|
|
14
|
+
program
|
|
15
|
+
.command('switch')
|
|
16
|
+
.description('Switch to a different Syphin project')
|
|
17
|
+
.option('--env <env>', 'Change environment too')
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
const token = loadToken()
|
|
20
|
+
if (!token) {
|
|
21
|
+
fail('Not authenticated. Run `syphin login` first.')
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const config = loadProjectConfig()
|
|
26
|
+
if (!config) {
|
|
27
|
+
fail('No project config found. Run `syphin init` first.')
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const api = createApiClient(token)
|
|
32
|
+
|
|
33
|
+
const spinner = ora('Fetching your projects...').start()
|
|
34
|
+
let projects
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
projects = await api.listProjects()
|
|
38
|
+
spinner.stop()
|
|
39
|
+
} catch (err) {
|
|
40
|
+
spinner.fail('Failed to fetch projects')
|
|
41
|
+
fail(err.message)
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (projects.length === 0) {
|
|
46
|
+
fail('No projects found. Create one in the dashboard first.')
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log('')
|
|
51
|
+
info(`Current: ${teal(config.projectSlug)} (${config.environment})`)
|
|
52
|
+
console.log('')
|
|
53
|
+
|
|
54
|
+
const choices = projects.map(p => ({
|
|
55
|
+
title: p.slug === config.projectSlug
|
|
56
|
+
? `${p.name} ${dim(`(${p.slug})`)} ${teal('← current')}`
|
|
57
|
+
: `${p.name} ${dim(`(${p.slug})`)}`,
|
|
58
|
+
value: p,
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
const { project } = await prompts({
|
|
62
|
+
type: 'select',
|
|
63
|
+
name: 'project',
|
|
64
|
+
message: 'Switch to',
|
|
65
|
+
choices,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
if (project === undefined) {
|
|
69
|
+
process.exit(0)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (project.slug === config.projectSlug && !opts.env) {
|
|
73
|
+
info('Already connected to this project.')
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const env = opts.env || config.environment
|
|
78
|
+
|
|
79
|
+
saveProjectConfig({
|
|
80
|
+
projectSlug: project.slug,
|
|
81
|
+
environment: env,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Show manifest summary
|
|
85
|
+
try {
|
|
86
|
+
const manifest = await api.getManifest(project.slug, env)
|
|
87
|
+
console.log('')
|
|
88
|
+
info(`Always-on skills: ${teal(manifest.alwaysOn.length.toString())}`)
|
|
89
|
+
info(`Available skills: ${teal(manifest.available.length.toString())}`)
|
|
90
|
+
info(`Total always-on tokens: ${teal('~' + manifest.totalTokens)}`)
|
|
91
|
+
} catch {
|
|
92
|
+
info('No manifest yet — bind skills to this project in the dashboard.')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log('')
|
|
96
|
+
success(`Switched to ${teal(project.name)} (${env}). Restart Claude Code to activate.`)
|
|
97
|
+
})
|
|
98
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { Command } from 'commander'
|
|
7
7
|
import { registerLogin } from './commands/login.js'
|
|
8
8
|
import { registerInit } from './commands/init.js'
|
|
9
|
+
import { registerSwitch } from './commands/switch.js'
|
|
9
10
|
import { registerAuth } from './commands/auth.js'
|
|
10
11
|
import { registerCache } from './commands/cache.js'
|
|
11
12
|
import { loadToken, loadProjectConfig } from './lib/config.js'
|
|
@@ -21,6 +22,7 @@ program
|
|
|
21
22
|
|
|
22
23
|
registerLogin(program)
|
|
23
24
|
registerInit(program)
|
|
25
|
+
registerSwitch(program)
|
|
24
26
|
registerAuth(program)
|
|
25
27
|
registerCache(program)
|
|
26
28
|
|
package/src/lib/api.js
CHANGED
|
@@ -28,9 +28,31 @@ export function createApiClient(token, apiUrl) {
|
|
|
28
28
|
return json.data
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Device flow endpoints don't need auth
|
|
32
|
+
async function requestNoAuth(method, path, body) {
|
|
33
|
+
const url = `${baseUrl}${path}`
|
|
34
|
+
const opts = {
|
|
35
|
+
method,
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
}
|
|
38
|
+
if (body) opts.body = JSON.stringify(body)
|
|
39
|
+
|
|
40
|
+
const res = await fetch(url, opts)
|
|
41
|
+
const json = await res.json()
|
|
42
|
+
|
|
43
|
+
if (!res.ok && res.status !== 202) {
|
|
44
|
+
throw new Error(json.error || `API error ${res.status}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return json.data
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
return {
|
|
32
51
|
verify: () => request('POST', '/api/auth/verify'),
|
|
52
|
+
listProjects: () => request('GET', '/api/projects'),
|
|
33
53
|
getManifest: (slug, env) => request('GET', `/api/projects/${slug}/manifest?env=${env}`),
|
|
34
54
|
createProject: (name, slug) => request('POST', '/api/projects', { name, slug }),
|
|
55
|
+
requestDeviceCode: () => requestNoAuth('POST', '/api/auth/device/code'),
|
|
56
|
+
pollDeviceToken: (deviceCode) => requestNoAuth('POST', '/api/auth/device/token', { deviceCode }),
|
|
35
57
|
}
|
|
36
58
|
}
|