askimo 1.6.0 → 1.7.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 CHANGED
@@ -36,6 +36,7 @@ OPENAI_MODEL=gpt-4o
36
36
  ANTHROPIC_MODEL=claude-sonnet-4-20250514
37
37
  XAI_MODEL=grok-4
38
38
  GEMINI_MODEL=gemini-3-pro-preview
39
+ IMAGE_MODEL=gemini-3-pro-image-preview
39
40
  ```
40
41
 
41
42
  ---
@@ -65,6 +66,16 @@ askimo "what's happening today?" -x # Use xAI Grok
65
66
  askimo "summarize this topic" -g # Use Google Gemini
66
67
  ```
67
68
 
69
+ ### Generate an image
70
+
71
+ ```bash
72
+ askimo --image "a cat wearing a top hat" # Save to current directory
73
+ askimo --image -d ./images "a sunset over ocean" # Save to ./images/
74
+ askimo --image --json "a dog on a skateboard" # JSON output with image paths
75
+ ```
76
+
77
+ Uses Google Gemini's image generation model. Requires `GOOGLE_GENERATIVE_AI_API_KEY` in your config.
78
+
68
79
  ### Continue a conversation
69
80
 
70
81
  ```bash
@@ -123,14 +134,15 @@ askimo conversations # Opens browser with all conversations
123
134
 
124
135
  ## ✨ Features
125
136
 
126
- | Feature | Description |
127
- |----------------|---------------------------------------------------|
128
- | Streaming | Real-time response output |
129
- | Piping | Pipe content via stdin |
130
- | File input | Read content from files with `-f` |
131
- | Citations | Source links with Perplexity |
132
- | History | Conversations saved to `~/.askimo/conversations/` |
133
- | Multi-provider | Switch between AI providers easily |
137
+ | Feature | Description |
138
+ |------------------|---------------------------------------------------|
139
+ | Streaming | Real-time response output |
140
+ | Image generation | Generate images with `-i` (uses Gemini) |
141
+ | Piping | Pipe content via stdin |
142
+ | File input | Read content from files with `-f` |
143
+ | Citations | Source links with Perplexity |
144
+ | History | Conversations saved to `~/.askimo/conversations/` |
145
+ | Multi-provider | Switch between AI providers easily |
134
146
 
135
147
  ---
136
148
 
package/index.mjs CHANGED
@@ -5,9 +5,17 @@ import { startChat } from './lib/chat.mjs'
5
5
  import { ensureDirectories, loadConfig } from './lib/config.mjs'
6
6
  import { createConversation, loadConversation, loadConversationById, saveConversation } from './lib/conversation.mjs'
7
7
  import { showConversationsInBrowser } from './lib/conversationsUi.mjs'
8
+ import { saveImages } from './lib/image.mjs'
8
9
  import { buildMessage, readFile, readStdin } from './lib/input.mjs'
9
- import { DEFAULT_MODELS, determineProvider, getProvider, listModels } from './lib/providers.mjs'
10
- import { generateResponse, outputJson, printResponse, streamResponse } from './lib/stream.mjs'
10
+ import { DEFAULT_MODELS, determineProvider, getImageProvider, getProvider, listModels } from './lib/providers.mjs'
11
+ import {
12
+ generateImageResponse,
13
+ generateResponse,
14
+ outputJson,
15
+ printImageResponse,
16
+ printResponse,
17
+ streamResponse
18
+ } from './lib/stream.mjs'
11
19
  import pkg from './package.json' with { type: 'json' }
12
20
 
13
21
  const program = new Command()
@@ -28,6 +36,8 @@ program
28
36
  .option('-c, --continue <n>', 'Continue conversation N (1=last, 2=second-to-last)', Number.parseInt)
29
37
  .option('--cid <id>', 'Continue conversation by ID')
30
38
  .option('-f, --file <path>', 'Read content from file')
39
+ .option('-i, --image', 'Generate an image (uses Gemini)')
40
+ .option('-d, --output-dir <path>', 'Directory to save generated images (default: current directory)')
31
41
  .action(async (question, options) => {
32
42
  try {
33
43
  const stdinContent = await readStdin()
@@ -49,8 +59,19 @@ program
49
59
  const config = await loadConfig()
50
60
  await ensureDirectories()
51
61
 
52
- const providerName = determineProvider(options, config)
53
- const { model, name, modelName } = getProvider(providerName, config)
62
+ let model
63
+ let name
64
+ let modelName
65
+
66
+ if (options.image) {
67
+ if (options.perplexity || options.openai || options.anthropic || options.xai) {
68
+ console.error('Note: --image uses Gemini regardless of other provider flags')
69
+ }
70
+ ;({ model, name, modelName } = getImageProvider(config))
71
+ } else {
72
+ const providerName = determineProvider(options, config)
73
+ ;({ model, name, modelName } = getProvider(providerName, config))
74
+ }
54
75
 
55
76
  let conversation
56
77
  let existingPath = null
@@ -87,7 +108,23 @@ program
87
108
 
88
109
  let responseText
89
110
 
90
- if (options.json) {
111
+ if (options.image) {
112
+ const { text, files, duration } = await generateImageResponse(model, conversation.messages)
113
+ const imagePaths = await saveImages(files, options.outputDir)
114
+ responseText = text || ''
115
+ conversation.messages.push({
116
+ role: 'assistant',
117
+ content: responseText,
118
+ images: imagePaths
119
+ })
120
+ await saveConversation(conversation, existingPath)
121
+
122
+ if (options.json) {
123
+ outputJson(conversation, responseText, undefined, duration, imagePaths)
124
+ } else {
125
+ printImageResponse(responseText, imagePaths, duration, modelName)
126
+ }
127
+ } else if (options.json) {
91
128
  const { text, sources, duration } = await generateResponse(model, conversation.messages)
92
129
  responseText = text
93
130
  conversation.messages.push({
package/lib/config.mjs CHANGED
@@ -51,4 +51,4 @@ async function ensureDirectories() {
51
51
  await mkdir(CONVERSATIONS_DIR, { recursive: true })
52
52
  }
53
53
 
54
- export { loadConfig, ensureDirectories, parseConfig, ASKIMO_DIR, CONVERSATIONS_DIR }
54
+ export { ASKIMO_DIR, CONVERSATIONS_DIR, ensureDirectories, loadConfig, parseConfig }
@@ -127,4 +127,4 @@ async function saveConversation(conversation, existingPath = null) {
127
127
  return filePath
128
128
  }
129
129
 
130
- export { createConversation, loadConversation, loadConversationById, getAllConversations, saveConversation }
130
+ export { createConversation, getAllConversations, loadConversation, loadConversationById, saveConversation }
@@ -386,4 +386,4 @@ async function showConversationsInBrowser() {
386
386
  hcat(html, { port: 0 })
387
387
  }
388
388
 
389
- export { showConversationsInBrowser, generateHtml }
389
+ export { generateHtml, showConversationsInBrowser }
package/lib/image.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ const MEDIA_TYPE_EXTENSIONS = {
5
+ 'image/png': '.png',
6
+ 'image/jpeg': '.jpg',
7
+ 'image/webp': '.webp'
8
+ }
9
+
10
+ function buildImagePath(outputDir, timestamp, index, mediaType) {
11
+ const ext = MEDIA_TYPE_EXTENSIONS[mediaType] || '.png'
12
+ const safestamp = timestamp.replace(/:/g, '-')
13
+ const filename = `askimo-${safestamp}-${index}${ext}`
14
+ return path.join(outputDir, filename)
15
+ }
16
+
17
+ async function saveImages(files, outputDir) {
18
+ if (!files?.length) return []
19
+
20
+ const dir = outputDir || process.cwd()
21
+ await fs.mkdir(dir, { recursive: true })
22
+
23
+ const timestamp = new Date().toISOString()
24
+ const savedPaths = []
25
+
26
+ for (let i = 0; i < files.length; i++) {
27
+ const file = files[i]
28
+ const filePath = buildImagePath(dir, timestamp, i + 1, file.mediaType)
29
+ await fs.writeFile(filePath, file.uint8Array)
30
+ savedPaths.push(filePath)
31
+ }
32
+
33
+ return savedPaths
34
+ }
35
+
36
+ export { buildImagePath, saveImages }
package/lib/input.mjs CHANGED
@@ -52,4 +52,4 @@ function buildMessage(prompt, content) {
52
52
  return content || prompt || null
53
53
  }
54
54
 
55
- export { readStdin, readFile, buildMessage }
55
+ export { buildMessage, readFile, readStdin }
package/lib/providers.mjs CHANGED
@@ -12,6 +12,8 @@ const DEFAULT_MODELS = {
12
12
  gemini: 'gemini-3-pro-preview'
13
13
  }
14
14
 
15
+ const DEFAULT_IMAGE_MODEL = 'gemini-3-pro-image-preview'
16
+
15
17
  // Provider name mapping (askimo name → models.dev name)
16
18
  const PROVIDER_MAP = {
17
19
  gemini: 'google'
@@ -112,6 +114,20 @@ function getProvider(providerName, config) {
112
114
  }
113
115
  }
114
116
 
117
+ function getImageProvider(config) {
118
+ const apiKey = config.GOOGLE_GENERATIVE_AI_API_KEY
119
+ if (!apiKey) {
120
+ throw new Error('GOOGLE_GENERATIVE_AI_API_KEY not found in config')
121
+ }
122
+ const modelName = config.IMAGE_MODEL || DEFAULT_IMAGE_MODEL
123
+ const google = createGoogleGenerativeAI({ apiKey })
124
+ return {
125
+ model: google(modelName),
126
+ name: 'gemini',
127
+ modelName
128
+ }
129
+ }
130
+
115
131
  function determineProvider(options, config = {}) {
116
132
  if (options.openai) return 'openai'
117
133
  if (options.anthropic) return 'anthropic'
@@ -127,4 +143,4 @@ function determineProvider(options, config = {}) {
127
143
  return 'perplexity'
128
144
  }
129
145
 
130
- export { getProvider, determineProvider, DEFAULT_MODELS, listModels }
146
+ export { DEFAULT_MODELS, determineProvider, getImageProvider, getProvider, listModels }
package/lib/stream.mjs CHANGED
@@ -55,6 +55,22 @@ async function generateResponse(model, messages) {
55
55
  return { text, sources, duration }
56
56
  }
57
57
 
58
+ async function generateImageResponse(model, messages) {
59
+ const startTime = Date.now()
60
+ process.stdout.write('\x1b[2mGenerating image...\x1b[0m')
61
+
62
+ const { text, files } = await generateText({
63
+ model,
64
+ messages
65
+ })
66
+
67
+ process.stdout.write('\r\x1b[K')
68
+
69
+ const duration = Date.now() - startTime
70
+ const imageFiles = files?.filter((f) => f.mediaType?.startsWith('image/'))
71
+ return { text, files: imageFiles, duration }
72
+ }
73
+
58
74
  function printResponse(text, sources, duration, modelName) {
59
75
  process.stdout.write(text)
60
76
  process.stdout.write('\n')
@@ -74,7 +90,7 @@ function printResponse(text, sources, duration, modelName) {
74
90
  process.stdout.write(`\n\x1b[2m${modelName} · ${formatDuration(duration)}\x1b[0m\n`)
75
91
  }
76
92
 
77
- function buildJsonOutput(conversation, response, sources, duration) {
93
+ function buildJsonOutput(conversation, response, sources, duration, images) {
78
94
  const lastUserMessage = conversation.messages.findLast((m) => m.role === 'user')
79
95
  const output = {
80
96
  provider: conversation.provider,
@@ -90,12 +106,43 @@ function buildJsonOutput(conversation, response, sources, duration) {
90
106
  output.sources = sources
91
107
  }
92
108
 
109
+ if (images?.length > 0) {
110
+ output.images = images
111
+ }
112
+
93
113
  return output
94
114
  }
95
115
 
96
- function outputJson(conversation, response, sources, duration) {
97
- const output = buildJsonOutput(conversation, response, sources, duration)
116
+ function printImageResponse(text, imagePaths, duration, modelName) {
117
+ if (text) {
118
+ process.stdout.write(text)
119
+ process.stdout.write('\n')
120
+ }
121
+
122
+ if (imagePaths?.length > 0) {
123
+ for (const imgPath of imagePaths) {
124
+ process.stdout.write(`\x1b[2m[image]\x1b[0m ${imgPath}\n`)
125
+ }
126
+ }
127
+
128
+ if (!text && !imagePaths?.length) {
129
+ process.stdout.write('No image or text was generated.\n')
130
+ }
131
+
132
+ process.stdout.write(`\n\x1b[2m${modelName} · ${formatDuration(duration)}\x1b[0m\n`)
133
+ }
134
+
135
+ function outputJson(conversation, response, sources, duration, images) {
136
+ const output = buildJsonOutput(conversation, response, sources, duration, images)
98
137
  console.log(JSON.stringify(output, null, 2))
99
138
  }
100
139
 
101
- export { streamResponse, generateResponse, printResponse, outputJson, buildJsonOutput }
140
+ export {
141
+ buildJsonOutput,
142
+ generateImageResponse,
143
+ generateResponse,
144
+ outputJson,
145
+ printImageResponse,
146
+ printResponse,
147
+ streamResponse
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "askimo",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "A CLI tool for communicating with AI providers (Perplexity, OpenAI, Anthropic)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Amit Tal",
@@ -28,17 +28,17 @@
28
28
  "repository": "https://github.com/amitosdev/askimo.git",
29
29
  "homepage": "https://github.com/amitosdev/askimo#readme",
30
30
  "devDependencies": {
31
- "@biomejs/biome": "^2.4.4",
31
+ "@biomejs/biome": "^2.4.8",
32
32
  "ava": "^6.4.1"
33
33
  },
34
34
  "dependencies": {
35
- "@ai-sdk/anthropic": "^3.0.46",
36
- "@ai-sdk/google": "^3.0.30",
37
- "@ai-sdk/openai": "^3.0.30",
38
- "@ai-sdk/perplexity": "^3.0.19",
39
- "@ai-sdk/xai": "^3.0.57",
40
- "@inquirer/input": "^5.0.7",
41
- "ai": "^6.0.97",
35
+ "@ai-sdk/anthropic": "^3.0.64",
36
+ "@ai-sdk/google": "^3.0.53",
37
+ "@ai-sdk/openai": "^3.0.48",
38
+ "@ai-sdk/perplexity": "^3.0.26",
39
+ "@ai-sdk/xai": "^3.0.74",
40
+ "@inquirer/input": "^5.0.10",
41
+ "ai": "^6.0.138",
42
42
  "commander": "^14.0.3",
43
43
  "hcat": "^2.2.1"
44
44
  },
package/test/image.mjs ADDED
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import test from 'ava'
4
+ import { buildImagePath, saveImages } from '../lib/image.mjs'
5
+
6
+ test('saveImages returns empty array when files is undefined', async (t) => {
7
+ const result = await saveImages(undefined)
8
+ t.deepEqual(result, [])
9
+ })
10
+
11
+ test('saveImages returns empty array when files is empty', async (t) => {
12
+ const result = await saveImages([])
13
+ t.deepEqual(result, [])
14
+ })
15
+
16
+ test('saveImages writes files and returns paths', async (t) => {
17
+ const absDir = path.join(process.cwd(), `test-saveImages-${Date.now()}`)
18
+
19
+ const files = [
20
+ { mediaType: 'image/png', uint8Array: Buffer.from('fake-png') },
21
+ { mediaType: 'image/jpeg', uint8Array: Buffer.from('fake-jpg') }
22
+ ]
23
+
24
+ const paths = await saveImages(files, absDir)
25
+
26
+ t.is(paths.length, 2)
27
+ t.true(paths[0].endsWith('.png'))
28
+ t.true(paths[1].endsWith('.jpg'))
29
+
30
+ const content0 = await fs.readFile(paths[0])
31
+ t.deepEqual(content0, Buffer.from('fake-png'))
32
+
33
+ const content1 = await fs.readFile(paths[1])
34
+ t.deepEqual(content1, Buffer.from('fake-jpg'))
35
+
36
+ // cleanup
37
+ await fs.rm(absDir, { recursive: true })
38
+ })
39
+
40
+ test('buildImagePath uses correct extension for png', (t) => {
41
+ const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/png')
42
+ t.true(result.endsWith('-1.png'))
43
+ })
44
+
45
+ test('buildImagePath uses correct extension for jpeg', (t) => {
46
+ const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/jpeg')
47
+ t.true(result.endsWith('-1.jpg'))
48
+ })
49
+
50
+ test('buildImagePath uses correct extension for webp', (t) => {
51
+ const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/webp')
52
+ t.true(result.endsWith('-1.webp'))
53
+ })
54
+
55
+ test('buildImagePath defaults to .png for unknown media type', (t) => {
56
+ const result = buildImagePath('/tmp', '2026-01-01T00:00:00.000Z', 1, 'image/bmp')
57
+ t.true(result.endsWith('-1.png'))
58
+ })
59
+
60
+ test('buildImagePath replaces colons in timestamp', (t) => {
61
+ const result = buildImagePath('/tmp', '2026-01-01T12:30:45.000Z', 1, 'image/png')
62
+ t.false(result.includes(':'))
63
+ })
package/test/stream.mjs CHANGED
@@ -101,3 +101,22 @@ test('buildJsonOutput excludes sources when null', (t) => {
101
101
  const output = buildJsonOutput(conversation, 'response', null)
102
102
  t.false('sources' in output)
103
103
  })
104
+
105
+ test('buildJsonOutput includes images when provided', (t) => {
106
+ const conversation = createMockConversation()
107
+ const images = ['/tmp/askimo-1.png', '/tmp/askimo-2.png']
108
+ const output = buildJsonOutput(conversation, 'response', undefined, undefined, images)
109
+ t.deepEqual(output.images, images)
110
+ })
111
+
112
+ test('buildJsonOutput excludes images when empty array', (t) => {
113
+ const conversation = createMockConversation()
114
+ const output = buildJsonOutput(conversation, 'response', undefined, undefined, [])
115
+ t.false('images' in output)
116
+ })
117
+
118
+ test('buildJsonOutput excludes images when undefined', (t) => {
119
+ const conversation = createMockConversation()
120
+ const output = buildJsonOutput(conversation, 'response', undefined, undefined, undefined)
121
+ t.false('images' in output)
122
+ })