kimaki 0.4.83 → 0.4.85
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/dist/cli.js +9 -2
- package/dist/commands/screenshare.js +14 -6
- package/dist/commands/screenshare.test.js +20 -0
- package/dist/config.js +16 -1
- package/dist/discord-bot.js +12 -59
- package/dist/discord-command-registration.js +1 -1
- package/dist/external-opencode-sync.js +40 -63
- package/dist/gateway-proxy.e2e.test.js +8 -56
- package/dist/onboarding-tutorial.js +1 -1
- package/dist/queue-advanced-e2e-setup.js +36 -0
- package/dist/queue-question-select-drain.e2e.test.js +117 -0
- package/dist/session-handler/thread-session-runtime.js +50 -1
- package/dist/store.js +1 -0
- package/dist/system-message.js +16 -4
- package/package.json +5 -4
- package/skills/errore/SKILL.md +40 -13
- package/skills/goke/SKILL.md +12 -0
- package/skills/lintcn/SKILL.md +868 -0
- package/skills/spiceflow/SKILL.md +1 -1
- package/src/cli.ts +15 -1
- package/src/commands/screenshare.test.ts +30 -0
- package/src/commands/screenshare.ts +18 -6
- package/src/config.ts +19 -1
- package/src/discord-bot.ts +13 -70
- package/src/discord-command-registration.ts +1 -1
- package/src/external-opencode-sync.ts +40 -73
- package/src/gateway-proxy.e2e.test.ts +8 -67
- package/src/genai.ts +2 -2
- package/src/onboarding-tutorial.ts +1 -1
- package/src/queue-advanced-e2e-setup.ts +37 -0
- package/src/queue-question-select-drain.e2e.test.ts +149 -0
- package/src/session-handler/thread-session-runtime.ts +68 -1
- package/src/store.ts +8 -0
- package/src/system-message.ts +16 -4
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// E2e test: queued message must drain after the user answers a pending question
|
|
2
|
+
// via the Discord dropdown select menu. Reproduces a bug where answering via
|
|
3
|
+
// select (not text) leaves queued messages stuck because the session continues
|
|
4
|
+
// processing after the answer and may enter another blocking state.
|
|
5
|
+
import { describe, test, expect } from 'vitest';
|
|
6
|
+
import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
|
|
7
|
+
import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
8
|
+
import { pendingQuestionContexts } from './commands/ask-question.js';
|
|
9
|
+
const TEXT_CHANNEL_ID = '200000000000001030';
|
|
10
|
+
async function waitForPendingQuestion({ threadId, timeoutMs, }) {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
while (Date.now() - start < timeoutMs) {
|
|
13
|
+
const entry = [...pendingQuestionContexts.entries()].find(([, context]) => {
|
|
14
|
+
return context.thread.id === threadId;
|
|
15
|
+
});
|
|
16
|
+
if (entry) {
|
|
17
|
+
return { contextHash: entry[0] };
|
|
18
|
+
}
|
|
19
|
+
await new Promise((resolve) => {
|
|
20
|
+
setTimeout(resolve, 100);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
throw new Error('Timed out waiting for pending question context');
|
|
24
|
+
}
|
|
25
|
+
describe('queue drain after question select answer', () => {
|
|
26
|
+
const ctx = setupQueueAdvancedSuite({
|
|
27
|
+
channelId: TEXT_CHANNEL_ID,
|
|
28
|
+
channelName: 'qa-question-select-drain',
|
|
29
|
+
dirName: 'qa-question-select-drain',
|
|
30
|
+
username: 'question-select-tester',
|
|
31
|
+
});
|
|
32
|
+
test('queued message drains after answering question via dropdown select', async () => {
|
|
33
|
+
// 1. Send a message that triggers the question tool
|
|
34
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
35
|
+
content: 'QUESTION_SELECT_QUEUE_MARKER',
|
|
36
|
+
});
|
|
37
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
38
|
+
timeout: 4_000,
|
|
39
|
+
predicate: (t) => {
|
|
40
|
+
return t.name === 'QUESTION_SELECT_QUEUE_MARKER';
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const th = ctx.discord.thread(thread.id);
|
|
44
|
+
// 2. Wait for the question dropdown to appear
|
|
45
|
+
const pending = await waitForPendingQuestion({
|
|
46
|
+
threadId: thread.id,
|
|
47
|
+
timeoutMs: 4_000,
|
|
48
|
+
});
|
|
49
|
+
expect(pending.contextHash).toBeTruthy();
|
|
50
|
+
// Verify dropdown message appeared
|
|
51
|
+
const questionMessages = await waitForBotMessageContaining({
|
|
52
|
+
discord: ctx.discord,
|
|
53
|
+
threadId: thread.id,
|
|
54
|
+
text: 'How to proceed?',
|
|
55
|
+
timeout: 4_000,
|
|
56
|
+
});
|
|
57
|
+
const questionMsg = questionMessages.find((m) => {
|
|
58
|
+
return m.content.includes('How to proceed?');
|
|
59
|
+
});
|
|
60
|
+
expect(questionMsg).toBeTruthy();
|
|
61
|
+
// 3. Queue a message while question is pending
|
|
62
|
+
const { id: queueInteractionId } = await th.user(TEST_USER_ID)
|
|
63
|
+
.runSlashCommand({
|
|
64
|
+
name: 'queue',
|
|
65
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: post-question-drain' }],
|
|
66
|
+
});
|
|
67
|
+
const queueAck = await th.waitForInteractionAck({
|
|
68
|
+
interactionId: queueInteractionId,
|
|
69
|
+
timeout: 4_000,
|
|
70
|
+
});
|
|
71
|
+
if (!queueAck.messageId) {
|
|
72
|
+
throw new Error('Expected /queue response message id');
|
|
73
|
+
}
|
|
74
|
+
// 4. Answer the question via dropdown select (pick first option "Alpha")
|
|
75
|
+
const interaction = await th.user(TEST_USER_ID).selectMenu({
|
|
76
|
+
messageId: questionMsg.id,
|
|
77
|
+
customId: `ask_question:${pending.contextHash}:0`,
|
|
78
|
+
values: ['0'],
|
|
79
|
+
});
|
|
80
|
+
await th.waitForInteractionAck({
|
|
81
|
+
interactionId: interaction.id,
|
|
82
|
+
timeout: 4_000,
|
|
83
|
+
});
|
|
84
|
+
// 5. Queued message should be handed off to OpenCode's own prompt queue
|
|
85
|
+
// after the question reply, so the dispatch indicator appears without
|
|
86
|
+
// waiting for a later natural idle.
|
|
87
|
+
await waitForBotMessageContaining({
|
|
88
|
+
discord: ctx.discord,
|
|
89
|
+
threadId: thread.id,
|
|
90
|
+
text: '» **question-select-tester:** Reply with exactly: post-question-drain',
|
|
91
|
+
timeout: 4_000,
|
|
92
|
+
});
|
|
93
|
+
// 6. Wait for footer from the drained queued message
|
|
94
|
+
await waitForFooterMessage({
|
|
95
|
+
discord: ctx.discord,
|
|
96
|
+
threadId: thread.id,
|
|
97
|
+
timeout: 4_000,
|
|
98
|
+
afterMessageIncludes: '» **question-select-tester:**',
|
|
99
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
100
|
+
});
|
|
101
|
+
const timeline = await th.text({ showInteractions: true });
|
|
102
|
+
expect(timeline).toMatchInlineSnapshot(`
|
|
103
|
+
"--- from: user (question-select-tester)
|
|
104
|
+
QUESTION_SELECT_QUEUE_MARKER
|
|
105
|
+
--- from: assistant (TestBot)
|
|
106
|
+
**Select action**
|
|
107
|
+
How to proceed?
|
|
108
|
+
✓ _Alpha_
|
|
109
|
+
[user interaction]
|
|
110
|
+
Queued message (position 1)
|
|
111
|
+
[user selects dropdown: 0]
|
|
112
|
+
» **question-select-tester:** Reply with exactly: post-question-drain
|
|
113
|
+
⬥ ok
|
|
114
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
115
|
+
`);
|
|
116
|
+
}, 20_000);
|
|
117
|
+
});
|
|
@@ -1805,6 +1805,49 @@ export class ThreadSessionRuntime {
|
|
|
1805
1805
|
return;
|
|
1806
1806
|
}
|
|
1807
1807
|
this.onInteractiveUiStateChanged();
|
|
1808
|
+
// When a question is answered and the local queue has items, the model may
|
|
1809
|
+
// continue the same run without ever reaching the local-queue idle gate.
|
|
1810
|
+
// Hand the queued items to OpenCode's own prompt queue immediately instead
|
|
1811
|
+
// of waiting for tryDrainQueue() to see an idle session.
|
|
1812
|
+
if (this.getQueueLength() > 0 && !this.questionReplyQueueHandoffPromise) {
|
|
1813
|
+
logger.log(`[QUESTION REPLIED] Queue has ${this.getQueueLength()} items, handing off to opencode queue`);
|
|
1814
|
+
this.questionReplyQueueHandoffPromise = this.handoffQueuedItemsAfterQuestionReply({
|
|
1815
|
+
sessionId,
|
|
1816
|
+
}).catch((error) => {
|
|
1817
|
+
logger.error('[QUESTION REPLIED] Failed to hand off queued messages:', error);
|
|
1818
|
+
if (error instanceof Error) {
|
|
1819
|
+
void notifyError(error, 'Failed to hand off queued messages after question reply');
|
|
1820
|
+
}
|
|
1821
|
+
}).finally(() => {
|
|
1822
|
+
this.questionReplyQueueHandoffPromise = null;
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
// Detached helper promise for the "question answered while local queue has
|
|
1827
|
+
// items" flow. Prevents starting two overlapping local->opencode queue
|
|
1828
|
+
// handoff sequences when multiple question replies land close together.
|
|
1829
|
+
questionReplyQueueHandoffPromise = null;
|
|
1830
|
+
async handoffQueuedItemsAfterQuestionReply({ sessionId, }) {
|
|
1831
|
+
if (this.listenerAborted) {
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if (this.state?.sessionId !== sessionId) {
|
|
1835
|
+
logger.log(`[QUESTION REPLIED] Session changed before queue handoff for thread ${this.threadId}`);
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
while (this.state?.sessionId === sessionId) {
|
|
1839
|
+
const next = threadState.dequeueItem(this.threadId);
|
|
1840
|
+
if (!next) {
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
const displayText = next.command
|
|
1844
|
+
? `/${next.command.name}`
|
|
1845
|
+
: `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
|
|
1846
|
+
if (displayText.trim()) {
|
|
1847
|
+
await sendThreadMessage(this.thread, `» **${next.username}:** ${displayText}`);
|
|
1848
|
+
}
|
|
1849
|
+
await this.submitViaOpencodeQueue(next);
|
|
1850
|
+
}
|
|
1808
1851
|
}
|
|
1809
1852
|
async handleSessionStatus(properties) {
|
|
1810
1853
|
const sessionId = this.state?.sessionId;
|
|
@@ -2624,12 +2667,18 @@ export class ThreadSessionRuntime {
|
|
|
2624
2667
|
if (input.command) {
|
|
2625
2668
|
const queuedCommand = input.command;
|
|
2626
2669
|
const commandSignal = AbortSignal.timeout(30_000);
|
|
2670
|
+
// session.command() only accepts FilePart in parts, not text parts.
|
|
2671
|
+
// Append <discord-user /> tag to arguments so external sync can
|
|
2672
|
+
// detect this message came from Discord (same tag as promptAsync).
|
|
2673
|
+
const discordTag = input.username
|
|
2674
|
+
? `\n<discord-user name="${input.username}" />`
|
|
2675
|
+
: '';
|
|
2627
2676
|
const commandResponse = await errore.tryAsync(() => {
|
|
2628
2677
|
return getClient().session.command({
|
|
2629
2678
|
sessionID: session.id,
|
|
2630
2679
|
directory: this.sdkDirectory,
|
|
2631
2680
|
command: queuedCommand.name,
|
|
2632
|
-
arguments: queuedCommand.arguments,
|
|
2681
|
+
arguments: queuedCommand.arguments + discordTag,
|
|
2633
2682
|
agent: earlyAgentPreference,
|
|
2634
2683
|
...variantField,
|
|
2635
2684
|
}, { signal: commandSignal });
|
package/dist/store.js
CHANGED
package/dist/system-message.js
CHANGED
|
@@ -54,6 +54,18 @@ bunx critique --web "Short title describing the changes" --filter "src/config.ts
|
|
|
54
54
|
|
|
55
55
|
The string after \`--web\` becomes the diff page title — make it reflect what the changes do (e.g. "Add retry logic to API client", "Fix auth timeout bug").
|
|
56
56
|
|
|
57
|
+
### fetching user comments from critique diffs
|
|
58
|
+
|
|
59
|
+
Users can add line-level comments (annotations) on any critique diff page via the Agentation widget (bottom-right corner of the diff page). To read those comments:
|
|
60
|
+
|
|
61
|
+
\`\`\`bash
|
|
62
|
+
curl https://critique.work/v/<id>/annotations
|
|
63
|
+
\`\`\`
|
|
64
|
+
|
|
65
|
+
Returns \`text/markdown\` with each annotation showing the file, line, and comment text.
|
|
66
|
+
Use this when the user says they left comments on a critique diff and you need to read them.
|
|
67
|
+
You can also use WebFetch on \`https://critique.work/v/<id>/annotations\` to get the markdown directly.
|
|
68
|
+
|
|
57
69
|
### about critique
|
|
58
70
|
|
|
59
71
|
critique is an open source tool (MIT license) at https://github.com/remorses/critique.
|
|
@@ -131,7 +143,7 @@ Use random tunnel IDs by default. Only pass \`-t\` when exposing a service that
|
|
|
131
143
|
tmux new-session -d -s myapp-dev
|
|
132
144
|
|
|
133
145
|
# Run the dev server with kimaki tunnel inside the session
|
|
134
|
-
tmux send-keys -t myapp-dev "kimaki tunnel -p 3000 -- pnpm dev" Enter
|
|
146
|
+
tmux send-keys -t myapp-dev "kimaki tunnel --kill -p 3000 -- pnpm dev" Enter
|
|
135
147
|
\`\`\`
|
|
136
148
|
|
|
137
149
|
### getting the tunnel URL
|
|
@@ -146,15 +158,15 @@ tmux capture-pane -t myapp-dev -p | grep -i "tunnel"
|
|
|
146
158
|
\`\`\`bash
|
|
147
159
|
# Next.js project
|
|
148
160
|
tmux new-session -d -s projectname-nextjs-dev-3000
|
|
149
|
-
tmux send-keys -t nextjs-dev "kimaki tunnel -p 3000 -- pnpm dev" Enter
|
|
161
|
+
tmux send-keys -t nextjs-dev "kimaki tunnel --kill -p 3000 -- pnpm dev" Enter
|
|
150
162
|
|
|
151
163
|
# Vite project on port 5173
|
|
152
164
|
tmux new-session -d -s vite-dev-5173
|
|
153
|
-
tmux send-keys -t vite-dev "kimaki tunnel -p 5173 -- pnpm dev" Enter
|
|
165
|
+
tmux send-keys -t vite-dev "kimaki tunnel --kill -p 5173 -- pnpm dev" Enter
|
|
154
166
|
|
|
155
167
|
# Custom tunnel ID (only for intentionally public-safe services)
|
|
156
168
|
tmux new-session -d -s holocron-dev
|
|
157
|
-
tmux send-keys -t holocron-dev "kimaki tunnel -p 3000 -t holocron -- pnpm dev" Enter
|
|
169
|
+
tmux send-keys -t holocron-dev "kimaki tunnel --kill -p 3000 -t holocron -- pnpm dev" Enter
|
|
158
170
|
\`\`\`
|
|
159
171
|
|
|
160
172
|
### stopping the dev server
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.85",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"prisma": "7.4.2",
|
|
26
26
|
"tsx": "^4.20.5",
|
|
27
27
|
"discord-digital-twin": "^0.1.0",
|
|
28
|
-
"opencode-cached-provider": "^0.0.1",
|
|
29
28
|
"opencode-deterministic-provider": "^0.0.1",
|
|
29
|
+
"opencode-cached-provider": "^0.0.1",
|
|
30
30
|
"db": "^0.0.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"zod": "^4.3.6",
|
|
68
68
|
"zustand": "^5.0.11",
|
|
69
69
|
"errore": "^0.14.1",
|
|
70
|
-
"
|
|
71
|
-
"
|
|
70
|
+
"libsqlproxy": "^0.1.0",
|
|
71
|
+
"traforo": "^0.2.0"
|
|
72
72
|
},
|
|
73
73
|
"optionalDependencies": {
|
|
74
74
|
"@discordjs/opus": "^0.10.0",
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
},
|
|
80
80
|
"scripts": {
|
|
81
81
|
"dev": "tsx src/cli.ts",
|
|
82
|
+
"build": "pnpm generate && pnpm tsc",
|
|
82
83
|
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
83
84
|
"watch": "tsx scripts/watch-session.ts",
|
|
84
85
|
"generate": "prisma generate && pnpm generate:sql",
|
package/skills/errore/SKILL.md
CHANGED
|
@@ -432,11 +432,11 @@ return res.status(response.status).json(response.body)
|
|
|
432
432
|
|
|
433
433
|
> `matchError` routes by `_tag` and requires an `Error` fallback for plain Error instances. Use `matchErrorPartial` when you only need to handle some cases.
|
|
434
434
|
|
|
435
|
-
### Resource Cleanup (defer)
|
|
435
|
+
### Resource Cleanup (defer) — Replacing try/finally with `using`
|
|
436
436
|
|
|
437
|
-
|
|
437
|
+
`try/finally` has a structural problem: **every resource adds a nesting level**. Two resources = two levels of indentation. The business logic gets buried deeper with each resource, and cleanup is split across `finally` blocks far from where the resource was acquired. `await using` + `DisposableStack` keeps the function flat — one `cleanup.defer()` per resource, same indentation whether you have one resource or ten. Cleanup runs automatically in reverse order on every exit path.
|
|
438
438
|
|
|
439
|
-
**tsconfig requirement:** add `"ESNext.Disposable"` to `lib
|
|
439
|
+
**tsconfig requirement:** add `"ESNext.Disposable"` to `lib`:
|
|
440
440
|
|
|
441
441
|
```jsonc
|
|
442
442
|
{
|
|
@@ -446,28 +446,51 @@ errore ships `DisposableStack` and `AsyncDisposableStack` polyfills that work in
|
|
|
446
446
|
}
|
|
447
447
|
```
|
|
448
448
|
|
|
449
|
-
|
|
449
|
+
**Before — nested try/finally:**
|
|
450
450
|
|
|
451
451
|
```ts
|
|
452
|
-
|
|
452
|
+
async function importData(url: string, dbUrl: string) {
|
|
453
|
+
const db = await connectDb(dbUrl)
|
|
454
|
+
try {
|
|
455
|
+
const tmpFile = await createTempFile()
|
|
456
|
+
try {
|
|
457
|
+
const data = await (await fetch(url)).text()
|
|
458
|
+
await tmpFile.write(data)
|
|
459
|
+
await db.import(tmpFile.path)
|
|
460
|
+
return { rows: await db.count() }
|
|
461
|
+
} finally {
|
|
462
|
+
await tmpFile.delete()
|
|
463
|
+
}
|
|
464
|
+
} finally {
|
|
465
|
+
await db.close()
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
**After — flat with `await using`:**
|
|
453
471
|
|
|
454
|
-
|
|
472
|
+
```ts
|
|
473
|
+
async function importData(url: string, dbUrl: string): Promise<ImportError | { rows: number }> {
|
|
455
474
|
await using cleanup = new errore.AsyncDisposableStack()
|
|
456
475
|
|
|
457
|
-
const db = await connectDb().catch((e) => new
|
|
476
|
+
const db = await connectDb(dbUrl).catch((e) => new ImportError({ reason: 'db connect', cause: e }))
|
|
458
477
|
if (db instanceof Error) return db
|
|
459
478
|
cleanup.defer(() => db.close())
|
|
460
479
|
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
480
|
+
const tmpFile = await createTempFile()
|
|
481
|
+
cleanup.defer(() => tmpFile.delete())
|
|
482
|
+
|
|
483
|
+
const response = await fetch(url).catch((e) => new ImportError({ reason: 'fetch', cause: e }))
|
|
484
|
+
if (response instanceof Error) return response
|
|
464
485
|
|
|
465
|
-
|
|
466
|
-
|
|
486
|
+
await tmpFile.write(await response.text())
|
|
487
|
+
await db.import(tmpFile.path)
|
|
488
|
+
return { rows: await db.count() }
|
|
489
|
+
// cleanup: tmpFile.delete() → db.close()
|
|
467
490
|
}
|
|
468
491
|
```
|
|
469
492
|
|
|
470
|
-
> `await using` guarantees cleanup
|
|
493
|
+
> `await using` guarantees cleanup on every exit path — normal return, early error return, or exception. Resources release in LIFO order. Adding a resource is one line (`cleanup.defer()`), not another nesting level. The errore polyfill handles the runtime; the tsconfig `lib` entry handles the types.
|
|
471
494
|
|
|
472
495
|
### Fallback Values
|
|
473
496
|
|
|
@@ -608,6 +631,10 @@ for (const item of items) {
|
|
|
608
631
|
|
|
609
632
|
> Place `signal.aborted` checks **before** expensive operations (network, db writes, file I/O). Check `isAbortError` **after** async calls that received the signal. Both keep the function responsive to cancellation.
|
|
610
633
|
|
|
634
|
+
## Linting
|
|
635
|
+
|
|
636
|
+
If the project uses [lintcn](https://github.com/remorses/lintcn), read `docs/lintcn.md` for the `no-unhandled-error` rule that catches discarded `Error | T` return values.
|
|
637
|
+
|
|
611
638
|
## Pitfalls
|
|
612
639
|
|
|
613
640
|
### CustomError | Error is ambiguous when CustomError extends Error
|
package/skills/goke/SKILL.md
CHANGED
|
@@ -602,6 +602,18 @@ cli.version('1.0.0')
|
|
|
602
602
|
cli.parse()
|
|
603
603
|
```
|
|
604
604
|
|
|
605
|
+
## `openInBrowser(url)`
|
|
606
|
+
|
|
607
|
+
Opens a URL in the default browser. In non-TTY environments (CI, piped output, agents), prints the URL to stdout instead of opening a browser.
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
import { openInBrowser } from 'goke'
|
|
611
|
+
|
|
612
|
+
openInBrowser('https://example.com/dashboard')
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Use this after generating URLs (OAuth callbacks, dashboards, docs links) so interactive users get a browser tab and non-interactive environments get a printable URL.
|
|
616
|
+
|
|
605
617
|
## Exposing your CLI as a skill
|
|
606
618
|
|
|
607
619
|
When you build a CLI with goke, the optimal way to create a skill for it is a minimal SKILL.md that tells agents to run `--help` before using the CLI. This way descriptions, examples, and usage patterns live in the CLI code (collocated with the implementation) instead of a separate markdown file that can go stale.
|