@trydecember/cli 0.2.1 → 0.2.3

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
@@ -3,7 +3,7 @@
3
3
  "module": "src/index.ts",
4
4
  "type": "module",
5
5
  "private": false,
6
- "version": "0.2.1",
6
+ "version": "0.2.3",
7
7
  "engines": {
8
8
  "node": ">=18.0.0"
9
9
  },
@@ -13,19 +13,21 @@
13
13
  "scripts": {
14
14
  "build": "bun build ./src/index.ts --target=node --outfile=./dist/december.js"
15
15
  },
16
- "dependencies": {
16
+ "devDependencies": {
17
17
  "@december/agent": "workspace:*",
18
+ "@december/providers": "workspace:*",
18
19
  "@december/tools": "workspace:*",
19
20
  "@december/tui": "workspace:*",
20
21
  "fast-glob": "3.3.3",
21
22
  "ink": "7.1.0",
22
- "react": "19.2.7"
23
- },
24
- "devDependencies": {
23
+ "react": "19.2.7",
25
24
  "@types/bun": "latest",
26
25
  "react-devtools-core": "7.0.1"
27
26
  },
28
27
  "peerDependencies": {
29
28
  "typescript": "^5"
29
+ },
30
+ "dependencies": {
31
+ "open": "11.0.0"
30
32
  }
31
33
  }
package/src/auth.ts ADDED
@@ -0,0 +1,204 @@
1
+ import http from 'node:http'
2
+ import open from 'open'
3
+
4
+ export async function loginViaBrowser(
5
+ baseUrl: string = 'http://localhost:3000/cli/login'
6
+ ): Promise<{ token: string; email: string | null }> {
7
+ return new Promise((resolve, reject) => {
8
+ const server = http.createServer((req, res) => {
9
+ const url = new URL(req.url || '', `http://${req.headers.host}`)
10
+
11
+ if (url.pathname === '/callback') {
12
+ const token = url.searchParams.get('token')
13
+ const email = url.searchParams.get('email')
14
+
15
+ if (token) {
16
+ res.writeHead(200, { 'Content-Type': 'text/html' })
17
+ res.end(`
18
+ <html>
19
+ <head>
20
+ <title>Authorized</title>
21
+ <style>
22
+ body {
23
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
24
+ display: flex;
25
+ flex-direction: column;
26
+ justify-content: center;
27
+ align-items: center;
28
+ height: 100vh;
29
+ margin: 0;
30
+ background-color: #141414;
31
+ color: #fff;
32
+ text-align: center;
33
+ }
34
+ .container {
35
+ display: flex;
36
+ flex-direction: column;
37
+ align-items: center;
38
+ max-width: 380px;
39
+ width: 100%;
40
+ }
41
+ svg {
42
+ width: 42px;
43
+ height: 42px;
44
+ margin-bottom: 24px;
45
+ }
46
+ h1 {
47
+ font-size: 22px;
48
+ font-weight: 400;
49
+ margin: 0 0 4px 0;
50
+ letter-spacing: -0.025em;
51
+ }
52
+ p {
53
+ font-size: 13px;
54
+ color: #A3A3A3;
55
+ margin: 0;
56
+ }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <div class="container">
61
+ <svg viewBox="5 4 14 16" fill="none" stroke="white" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
62
+ <line x1="12" y1="12" x2="12" y2="5.3" />
63
+ <line x1="12" y1="7.5" x2="13.9" y2="6.4" />
64
+ <line x1="12" y1="7.5" x2="10.1" y2="6.4" />
65
+ <line x1="12" y1="12" x2="17.8" y2="8.65" />
66
+ <line x1="15.9" y1="9.75" x2="17.8" y2="10.85" />
67
+ <line x1="15.9" y1="9.75" x2="15.9" y2="7.55" />
68
+ <line x1="12" y1="12" x2="17.8" y2="15.35" />
69
+ <line x1="15.9" y1="14.25" x2="15.9" y2="16.45" />
70
+ <line x1="15.9" y1="14.25" x2="17.8" y2="13.15" />
71
+ <line x1="12" y1="12" x2="12" y2="18.7" />
72
+ <line x1="12" y1="16.5" x2="10.1" y2="17.6" />
73
+ <line x1="12" y1="16.5" x2="13.9" y2="17.6" />
74
+ <line x1="12" y1="12" x2="6.2" y2="15.35" />
75
+ <line x1="8.1" y1="14.25" x2="6.2" y2="13.15" />
76
+ <line x1="8.1" y1="14.25" x2="8.1" y2="16.45" />
77
+ <line x1="12" y1="12" x2="6.2" y2="8.65" />
78
+ <line x1="8.1" y1="9.75" x2="8.1" y2="7.55" />
79
+ <line x1="8.1" y1="9.75" x2="6.2" y2="10.85" />
80
+ </svg>
81
+ <h1>Authorized</h1>
82
+ <p>You can safely close this tab and return to the terminal.</p>
83
+ </div>
84
+ <script>
85
+ setTimeout(() => window.close(), 3000);
86
+ </script>
87
+ </body>
88
+ </html>
89
+ `)
90
+
91
+ server.close(() => {
92
+ resolve({ token, email })
93
+ })
94
+ } else {
95
+ res.writeHead(400, { 'Content-Type': 'text/plain' })
96
+ res.end('Missing token in callback.')
97
+ server.close(() => {
98
+ reject(new Error('Missing token in callback'))
99
+ })
100
+ }
101
+ } else {
102
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
103
+ res.end('Not found')
104
+ }
105
+ })
106
+
107
+ server.listen(0, '127.0.0.1', () => {
108
+ const address = server.address()
109
+ if (address && typeof address === 'object') {
110
+ const port = address.port
111
+ const redirectUri = `http://127.0.0.1:${port}/callback`
112
+ const loginUrl = `${baseUrl}?redirect_uri=${encodeURIComponent(redirectUri)}`
113
+
114
+ open(loginUrl).catch((err) => {
115
+ server.close()
116
+ reject(err)
117
+ })
118
+ } else {
119
+ server.close()
120
+ reject(new Error('Failed to start local server'))
121
+ }
122
+ })
123
+
124
+ // Timeout after 5 minutes
125
+ setTimeout(
126
+ () => {
127
+ server.close()
128
+ reject(new Error('Login timed out'))
129
+ },
130
+ 5 * 60 * 1000
131
+ )
132
+ })
133
+ }
134
+
135
+ export async function loginViaDeviceCode(
136
+ baseUrl: string = 'http://localhost:4000',
137
+ onCodeGenerated: (userCode: string, verificationUri: string) => void
138
+ ): Promise<{ token: string; email: string | null }> {
139
+ return new Promise(async (resolve, reject) => {
140
+ try {
141
+ // 1. Generate Code
142
+ const genRes = await fetch(`${baseUrl}/api/v1/auth/device/code`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ })
146
+
147
+ if (!genRes.ok) {
148
+ throw new Error('Failed to generate device code')
149
+ }
150
+
151
+ const genData = await genRes.json()
152
+ if (!genData.success) {
153
+ throw new Error(genData.message || 'Failed to generate device code')
154
+ }
155
+
156
+ const { deviceCode, userCode, verificationUri, expiresIn, interval } = genData.data
157
+
158
+ onCodeGenerated(userCode, verificationUri)
159
+
160
+ // 2. Poll for token
161
+ const startTime = Date.now()
162
+ const pollInterval = interval * 1000 || 5000
163
+
164
+ const poll = async () => {
165
+ if (Date.now() - startTime > expiresIn * 1000) {
166
+ reject(new Error('Device code expired'))
167
+ return
168
+ }
169
+
170
+ try {
171
+ const tokenRes = await fetch(`${baseUrl}/api/v1/auth/device/token`, {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify({ deviceCode }),
175
+ })
176
+
177
+ const tokenData = await tokenRes.json()
178
+
179
+ if (tokenRes.ok && tokenData.success) {
180
+ resolve({
181
+ token: tokenData.data.token,
182
+ email: tokenData.data.email,
183
+ })
184
+ return
185
+ }
186
+
187
+ // If not ok, check if it's authorization_pending
188
+ if (tokenData.message === 'authorization_pending') {
189
+ setTimeout(poll, pollInterval)
190
+ } else {
191
+ reject(new Error(tokenData.message || 'Polling failed'))
192
+ }
193
+ } catch (err) {
194
+ // Network errors during polling, just keep trying
195
+ setTimeout(poll, pollInterval)
196
+ }
197
+ }
198
+
199
+ setTimeout(poll, pollInterval)
200
+ } catch (err) {
201
+ reject(err)
202
+ }
203
+ })
204
+ }
package/src/index.ts CHANGED
@@ -4,35 +4,88 @@ import React from 'react'
4
4
  import { App } from '@december/tui'
5
5
  import {
6
6
  Agent,
7
- createProvider,
7
+ AgentHarness,
8
+ FileSessionRepository,
8
9
  ProviderConfig,
9
10
  getProviderConfig,
10
- OpenAIProvider,
11
+ loadConfig,
11
12
  } from '@december/agent'
13
+ import pkg from '../package.json' with { type: 'json' }
14
+ import {
15
+ OpenAIProvider,
16
+ AnthropicProvider,
17
+ GeminiProvider,
18
+ OpenRouterProvider,
19
+ } from '@december/providers'
12
20
  import {
13
21
  BashTool,
14
22
  ReadFileTool,
15
23
  WriteFileTool,
16
24
  LsTool,
17
25
  EditFileTool,
26
+ EditDiffTool,
18
27
  FindFilesTool,
19
28
  GrepSearchTool,
20
29
  SubagentTool,
21
30
  } from '@december/tools'
22
31
  import { localOperations } from './local-operations'
32
+ import { loginViaBrowser, loginViaDeviceCode } from './auth'
23
33
 
24
34
  async function main() {
25
35
  let providerConfig = await getProviderConfig()
26
36
 
27
37
  // If not authenticated, we pass a dummy provider so the Agent can boot.
28
38
  // The TUI will intercept prompts and force them to /login
29
- const llm = providerConfig
30
- ? createProvider(providerConfig)
31
- : new OpenAIProvider('dummy', undefined, 'gpt-4o')
39
+ let llm: any
40
+ if (providerConfig) {
41
+ switch (providerConfig.provider) {
42
+ case 'openai':
43
+ llm = new OpenAIProvider(undefined, providerConfig.apiKey)
44
+ break
45
+ case 'anthropic':
46
+ llm = new AnthropicProvider(providerConfig.apiKey)
47
+ break
48
+ case 'google':
49
+ case 'gemini':
50
+ llm = new GeminiProvider(providerConfig.apiKey)
51
+ break
52
+ case 'openrouter':
53
+ llm = new OpenRouterProvider(providerConfig.apiKey)
54
+ break
55
+ case 'deepseek':
56
+ llm = new OpenAIProvider('https://api.deepseek.com', providerConfig.apiKey)
57
+ break
58
+ case 'groq':
59
+ llm = new OpenAIProvider('https://api.groq.com/openai/v1', providerConfig.apiKey)
60
+ break
61
+ case 'huggingface':
62
+ llm = new OpenAIProvider(
63
+ 'https://api-inference.huggingface.co/v1',
64
+ providerConfig.apiKey
65
+ )
66
+ break
67
+ case 'kimi':
68
+ case 'moonshoot':
69
+ llm = new OpenAIProvider('https://api.moonshot.cn/v1', providerConfig.apiKey)
70
+ break
71
+ case 'mistral':
72
+ llm = new OpenAIProvider('https://api.mistral.ai/v1', providerConfig.apiKey)
73
+ break
74
+ case 'xai':
75
+ llm = new OpenAIProvider('https://api.x.ai/v1', providerConfig.apiKey)
76
+ break
77
+ case 'zai':
78
+ llm = new OpenAIProvider('https://api.zai.ai/v1', providerConfig.apiKey)
79
+ break
80
+ }
81
+ } else {
82
+ llm = new OpenAIProvider(undefined, 'dummy-key')
83
+ }
84
+
32
85
  const isAuthenticated = !!providerConfig
33
86
 
34
- const agent = new Agent({
35
- systemPrompt:
87
+ const harness = new AgentHarness({
88
+ baseSystemPrompt:
36
89
  'You are December, an autonomous software engineer. You have access to tools. When executing code, please use JSON schemas for tool inputs.',
37
90
  llm: llm,
38
91
  tools: [
@@ -41,16 +94,41 @@ async function main() {
41
94
  WriteFileTool,
42
95
  LsTool,
43
96
  EditFileTool,
97
+ EditDiffTool,
44
98
  FindFilesTool,
45
99
  GrepSearchTool,
46
100
  SubagentTool,
47
101
  ],
48
102
  operations: localOperations,
103
+ modelOptions: providerConfig?.model ? { model: providerConfig.model } : undefined,
104
+ sessionRepository: new FileSessionRepository(),
105
+ workspaceDir: process.cwd(),
106
+ hooks: {
107
+ beforeToolCall: async (toolCall) => {
108
+ // Future integration: Hook into the TUI to request user approval for destructive bash commands
109
+ },
110
+ },
49
111
  })
50
112
 
113
+ const agent = harness.getAgent()
114
+
51
115
  await agent.loadContext()
52
116
 
53
- render(React.createElement(App, { agent, isAuthenticated }), { exitOnCtrlC: false })
117
+ const config = await loadConfig()
118
+ const userEmail = config.decemberToken ? config.email : undefined
119
+
120
+ render(
121
+ React.createElement(App, {
122
+ agent,
123
+ isAuthenticated,
124
+ cliVersion: pkg.version,
125
+ userEmail,
126
+ onLogin: loginViaBrowser,
127
+ onLoginHeadless: (onCode: (code: string, uri: string) => void) =>
128
+ loginViaDeviceCode('http://localhost:4000', onCode), // Replace with prod URL later
129
+ }),
130
+ { exitOnCtrlC: false }
131
+ )
54
132
  }
55
133
 
56
134
  main().catch(console.error)