@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syphin/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Syphin CLI — centralized AI agent context",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- mcp.mcpServers.syphin = {
37
- command: 'npx',
38
- args: ['@syphin/mcp'],
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 projectName = detectProjectName()
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
- // Save project config
74
- saveProjectConfig({
75
- projectSlug: slug,
76
- environment: env,
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
- spinner.succeed('Connected!')
157
+ const env = opts.env
83
158
 
84
- // Try to fetch manifest as connection test
85
- try {
86
- const manifest = await api.getManifest(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 — 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
- success('Project connected. Restart Claude Code to activate Syphin.')
97
- } catch (err) {
98
- spinner.fail('Connection failed')
99
- fail(err.message)
100
- process.exit(1)
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
  }
@@ -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
- const token = opts.token
24
- if (!token) {
25
- fail('Token required. Use: syphin login --token <your-token>')
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
- const spinner = ora('Verifying token...').start()
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
- try {
33
- const api = createApiClient(token)
34
- const result = await api.verify()
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
- saveToken(token)
37
- spinner.succeed('Authenticated successfully!')
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
- } catch (err) {
44
- spinner.fail('Authentication failed')
45
- fail(err.message)
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
  }