typeclaw 0.35.1 → 0.36.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 +2 -1
- package/src/agent/session-origin.ts +4 -3
- package/src/agent/system-prompt.ts +1 -1
- package/src/bundled-plugins/doc-render/index.ts +20 -0
- package/src/bundled-plugins/doc-render/render.ts +140 -0
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +314 -0
- package/src/bundled-plugins/security/index.ts +15 -0
- package/src/bundled-plugins/security/permissions.ts +1 -0
- package/src/bundled-plugins/security/policies/plugin-addition.ts +240 -0
- package/src/channels/adapters/line.ts +12 -2
- package/src/cli/channel.ts +190 -7
- package/src/cli/inspect-select.ts +121 -0
- package/src/cli/inspect.ts +15 -7
- package/src/config/channels-mutation.ts +250 -0
- package/src/container/start.ts +24 -1
- package/src/init/reconcile-plugin-deps.ts +173 -0
- package/src/inspect/index.ts +5 -2
- package/src/inspect/loop.ts +26 -9
- package/src/inspect/render.ts +28 -1
- package/src/inspect/transcript-view.ts +52 -11
- package/src/plugin/index.ts +2 -2
- package/src/plugin/loader.ts +61 -7
- package/src/plugin/manager.ts +18 -4
- package/src/run/bundled-plugins.ts +2 -0
- package/src/secrets/storage.ts +27 -0
- package/src/skills/typeclaw-markdown-pdf/SKILL.md +0 -400
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { readFile, realpath } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parseConfigJson } from '@/config'
|
|
5
|
+
import { splitPluginEntrySpec } from '@/plugin'
|
|
6
|
+
|
|
7
|
+
import type { SecuritySeverity } from '../permissions'
|
|
8
|
+
import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
|
|
9
|
+
|
|
10
|
+
export const GUARD_PLUGIN_ADDITION = 'pluginAddition'
|
|
11
|
+
// Classified `medium` (silent-attack axis), same tier and rationale as
|
|
12
|
+
// `rolePromotion` and `cronPromotion`. Adding (or version-bumping) a plugin in
|
|
13
|
+
// typeclaw.json is a deferred host-code-execution grant: the entry is
|
|
14
|
+
// materialized into package.json by `reconcilePluginDeps` and installed by the
|
|
15
|
+
// next host `typeclaw start`, at which point npm lifecycle scripts run as the
|
|
16
|
+
// operator on the host. `plugins` is restart-required and typeclaw.json is
|
|
17
|
+
// force-committed by auto-backup, so the operator sees the change in `git log`
|
|
18
|
+
// and backup commits BEFORE any install fires — an operator-reviewable window
|
|
19
|
+
// between the privileged write and the privileged execution. Owner and trusted
|
|
20
|
+
// bypass without ack; member and guest are blocked.
|
|
21
|
+
//
|
|
22
|
+
// What counts as a plugin addition (any of):
|
|
23
|
+
// 1. A new entry appeared in `plugins[]` (by package name).
|
|
24
|
+
// 2. An existing entry's pinned version spec changed (`foo@1` -> `foo@2`):
|
|
25
|
+
// a different package version is a different body of host code, the same
|
|
26
|
+
// shape as cron's body-changed finding.
|
|
27
|
+
//
|
|
28
|
+
// What does NOT count (allowed without ack):
|
|
29
|
+
// - Removing an entry from `plugins[]` (a privilege REDUCTION; uninstalling
|
|
30
|
+
// runs no untrusted code).
|
|
31
|
+
// - Reordering entries.
|
|
32
|
+
// - Local-path entries (`./`, `../`, absolute): these are never installed
|
|
33
|
+
// from a registry, so there is no lifecycle-script execution to gate. They
|
|
34
|
+
// are confined to the agent dir by the loader.
|
|
35
|
+
//
|
|
36
|
+
// Failure-open is deliberate, same direction as the sibling guards: an
|
|
37
|
+
// unreadable/unparseable existing typeclaw.json makes every proposed entry look
|
|
38
|
+
// new and blocks. The only false positive is an operator authoring a fresh
|
|
39
|
+
// config with plugins, which they ack in the same call.
|
|
40
|
+
export const GUARD_PLUGIN_ADDITION_SEVERITY: SecuritySeverity = 'medium'
|
|
41
|
+
|
|
42
|
+
export type PluginAdditionFinding =
|
|
43
|
+
| { kind: 'plugin-added'; name: string; versionSpec: string }
|
|
44
|
+
| { kind: 'version-changed'; name: string; from: string; to: string }
|
|
45
|
+
|
|
46
|
+
export async function checkPluginAdditionGuard(options: {
|
|
47
|
+
tool: string
|
|
48
|
+
args: Record<string, unknown>
|
|
49
|
+
agentDir: string
|
|
50
|
+
}): Promise<SecurityBlock | undefined> {
|
|
51
|
+
const { tool, args, agentDir } = options
|
|
52
|
+
if (tool !== 'write' && tool !== 'edit') return undefined
|
|
53
|
+
|
|
54
|
+
const rawPath = args.path
|
|
55
|
+
if (typeof rawPath !== 'string') return undefined
|
|
56
|
+
|
|
57
|
+
const targetPath = path.resolve(agentDir, rawPath)
|
|
58
|
+
if (!(await pathIsTypeclawJson(agentDir, targetPath))) return undefined
|
|
59
|
+
|
|
60
|
+
if (isGuardAcknowledged(args, GUARD_PLUGIN_ADDITION)) return undefined
|
|
61
|
+
|
|
62
|
+
const editRefusal = refuseRiskyEdit(tool, args, targetPath)
|
|
63
|
+
if (editRefusal) return editRefusal
|
|
64
|
+
|
|
65
|
+
const newContent = await intendedContent(tool, args, targetPath)
|
|
66
|
+
if (newContent === undefined) return undefined
|
|
67
|
+
|
|
68
|
+
const newPlugins = parsePluginsFromContent(newContent)
|
|
69
|
+
if (newPlugins === undefined) return undefined
|
|
70
|
+
|
|
71
|
+
const oldPlugins = await readExistingPlugins(targetPath)
|
|
72
|
+
const findings = diffPlugins(oldPlugins, newPlugins)
|
|
73
|
+
if (findings.length === 0) return undefined
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
block: true,
|
|
77
|
+
reason: buildBlockReason(tool, targetPath, findings),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function diffPlugins(before: readonly string[], after: readonly string[]): PluginAdditionFinding[] {
|
|
82
|
+
const findings: PluginAdditionFinding[] = []
|
|
83
|
+
const beforeByName = new Map<string, string | undefined>()
|
|
84
|
+
for (const entry of before) {
|
|
85
|
+
if (isLocalEntry(entry)) continue
|
|
86
|
+
const { name, versionSpec } = splitPluginEntrySpec(entry)
|
|
87
|
+
beforeByName.set(name, versionSpec)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const entry of after) {
|
|
91
|
+
if (isLocalEntry(entry)) continue
|
|
92
|
+
const { name, versionSpec } = splitPluginEntrySpec(entry)
|
|
93
|
+
if (!beforeByName.has(name)) {
|
|
94
|
+
findings.push({ kind: 'plugin-added', name, versionSpec: versionSpec ?? '<latest>' })
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
const priorSpec = beforeByName.get(name)
|
|
98
|
+
if (priorSpec !== versionSpec) {
|
|
99
|
+
findings.push({
|
|
100
|
+
kind: 'version-changed',
|
|
101
|
+
name,
|
|
102
|
+
from: priorSpec ?? '<latest>',
|
|
103
|
+
to: versionSpec ?? '<latest>',
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return findings
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isLocalEntry(entry: string): boolean {
|
|
111
|
+
return entry.startsWith('./') || entry.startsWith('../') || path.isAbsolute(entry)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parsePluginsFromContent(content: string): readonly string[] | undefined {
|
|
115
|
+
const result = parseConfigJson(content, { migrate: false })
|
|
116
|
+
if (!result.ok) return undefined
|
|
117
|
+
return result.config.plugins ?? []
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function readExistingPlugins(targetPath: string): Promise<readonly string[]> {
|
|
121
|
+
let raw: string
|
|
122
|
+
try {
|
|
123
|
+
raw = await readFile(targetPath, 'utf8')
|
|
124
|
+
} catch {
|
|
125
|
+
return []
|
|
126
|
+
}
|
|
127
|
+
const result = parseConfigJson(raw, { migrate: false })
|
|
128
|
+
if (!result.ok) return []
|
|
129
|
+
return result.config.plugins ?? []
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// See the parallel rationale block in role-promotion.ts — Oracle PR #305
|
|
133
|
+
// findings #5 and #6 (symlinked managed file + case-insensitive FS).
|
|
134
|
+
async function pathIsTypeclawJson(agentDir: string, targetPath: string): Promise<boolean> {
|
|
135
|
+
const resolvedAgentDir = path.resolve(agentDir)
|
|
136
|
+
const canonicalManagedPath = path.join(resolvedAgentDir, 'typeclaw.json')
|
|
137
|
+
const resolvedTarget = path.resolve(targetPath)
|
|
138
|
+
if (canonicalManagedPath === resolvedTarget) return true
|
|
139
|
+
const realCanonical = await resolveRealPath(canonicalManagedPath)
|
|
140
|
+
const realTarget = await resolveRealPath(resolvedTarget)
|
|
141
|
+
return realCanonical === realTarget
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Symmetric with role/cron-promotion's refuseRiskyEdit. See Oracle PR #305
|
|
145
|
+
// finding #4: simulator-vs-real divergence on multi-edit, plus non-unique
|
|
146
|
+
// oldText ambiguity. Conservative refusal keeps the guard honest without
|
|
147
|
+
// re-implementing the edit-diff semantics inside the security plugin.
|
|
148
|
+
function refuseRiskyEdit(tool: string, args: Record<string, unknown>, targetPath: string): SecurityBlock | undefined {
|
|
149
|
+
if (tool !== 'edit') return undefined
|
|
150
|
+
const edits = args.edits
|
|
151
|
+
if (!Array.isArray(edits)) return undefined
|
|
152
|
+
if (edits.length > 1) {
|
|
153
|
+
return {
|
|
154
|
+
block: true,
|
|
155
|
+
reason: [
|
|
156
|
+
`Guard \`${GUARD_PLUGIN_ADDITION}\` refuses multi-edit on ${targetPath}: the security guard's edit simulator cannot match the pi-coding-agent edit tool's original-content semantics for multi-edit calls.`,
|
|
157
|
+
'Use `write` with the full file content instead — this is the canonical workflow for managed config files.',
|
|
158
|
+
].join(' '),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function intendedContent(
|
|
165
|
+
tool: string,
|
|
166
|
+
args: Record<string, unknown>,
|
|
167
|
+
targetPath: string,
|
|
168
|
+
): Promise<string | undefined> {
|
|
169
|
+
if (tool === 'write') {
|
|
170
|
+
return typeof args.content === 'string' ? args.content : undefined
|
|
171
|
+
}
|
|
172
|
+
const edits = args.edits
|
|
173
|
+
if (!Array.isArray(edits)) return undefined
|
|
174
|
+
let content: string
|
|
175
|
+
try {
|
|
176
|
+
content = await readFile(targetPath, 'utf8')
|
|
177
|
+
} catch {
|
|
178
|
+
return undefined
|
|
179
|
+
}
|
|
180
|
+
for (const edit of edits) {
|
|
181
|
+
if (!edit || typeof edit !== 'object') return undefined
|
|
182
|
+
const { oldText, newText } = edit as Record<string, unknown>
|
|
183
|
+
if (typeof oldText !== 'string' || typeof newText !== 'string') return undefined
|
|
184
|
+
if (oldText.length === 0) return undefined
|
|
185
|
+
const firstIdx = content.indexOf(oldText)
|
|
186
|
+
if (firstIdx === -1) return undefined
|
|
187
|
+
if (content.indexOf(oldText, firstIdx + 1) !== -1) return undefined
|
|
188
|
+
content = content.slice(0, firstIdx) + newText + content.slice(firstIdx + oldText.length)
|
|
189
|
+
}
|
|
190
|
+
return content
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildBlockReason(tool: string, targetPath: string, findings: readonly PluginAdditionFinding[]): string {
|
|
194
|
+
const lines: string[] = []
|
|
195
|
+
for (const f of findings) {
|
|
196
|
+
const name = sanitizeForReason(f.name)
|
|
197
|
+
if (f.kind === 'plugin-added') {
|
|
198
|
+
lines.push(`new plugin \`${name}\` (${sanitizeForReason(f.versionSpec)}) would be installed on the host`)
|
|
199
|
+
} else {
|
|
200
|
+
lines.push(
|
|
201
|
+
`plugin \`${name}\` version changes \`${sanitizeForReason(f.from)}\` -> \`${sanitizeForReason(f.to)}\``,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return [
|
|
206
|
+
`Guard \`${GUARD_PLUGIN_ADDITION}\` blocked ${tool} on ${sanitizeForReason(targetPath)}: this change introduces a deferred host-code-execution grant — ${lines.join('; ')}.`,
|
|
207
|
+
"Plugin entries in typeclaw.json#plugins are materialized into package.json and installed by the next host `typeclaw start`, where npm lifecycle scripts run as the operator on the host. Even an `owner` operating from TUI must not silently add plugins on behalf of a channel message: the canonical attack is a prompt-injected agent writing a malicious package name into plugins[] so the operator's next start runs its postinstall.",
|
|
208
|
+
`If this is genuinely intentional and the operator explicitly asked for it (not a channel message), retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_PLUGIN_ADDITION}: true\` in the tool arguments.`,
|
|
209
|
+
].join(' ')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const MAX_REASON_TOKEN_LEN = 200
|
|
213
|
+
|
|
214
|
+
function sanitizeForReason(value: string): string {
|
|
215
|
+
// eslint-disable-next-line no-control-regex
|
|
216
|
+
const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, '').replace(/`/g, "'")
|
|
217
|
+
if (cleaned.length <= MAX_REASON_TOKEN_LEN) return cleaned
|
|
218
|
+
return `${cleaned.slice(0, MAX_REASON_TOKEN_LEN)}...`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function resolveRealPath(absolutePath: string): Promise<string> {
|
|
222
|
+
const pending: string[] = []
|
|
223
|
+
let current = absolutePath
|
|
224
|
+
while (true) {
|
|
225
|
+
try {
|
|
226
|
+
const real = await realpath(current)
|
|
227
|
+
return path.join(real, ...pending.reverse())
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (!isNotFound(err)) throw err
|
|
230
|
+
}
|
|
231
|
+
const parent = path.dirname(current)
|
|
232
|
+
if (parent === current) return absolutePath
|
|
233
|
+
pending.push(path.basename(current))
|
|
234
|
+
current = parent
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isNotFound(err: unknown): boolean {
|
|
239
|
+
return err instanceof Error && 'code' in err && (err as { code: unknown }).code === 'ENOENT'
|
|
240
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
LineClient as RealLineClient,
|
|
3
|
+
type LineCredentialManager,
|
|
3
4
|
LineListener as RealLineListener,
|
|
4
5
|
type LineAccountCredentials,
|
|
5
6
|
type LineChat,
|
|
@@ -53,7 +54,7 @@ export type LineCredentialStore = {
|
|
|
53
54
|
getAccount(id?: string): Promise<LineAccountCredentials | null>
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
const LineClient = RealLineClient as unknown as new () => LineClient
|
|
57
|
+
const LineClient = RealLineClient as unknown as new (credManager?: LineCredentialManager) => LineClient
|
|
57
58
|
const LineListener = RealLineListener as unknown as new (client: LineClient) => LineListener
|
|
58
59
|
|
|
59
60
|
export type LineAdapterLogger = {
|
|
@@ -75,6 +76,7 @@ export type LineAdapterOptions = {
|
|
|
75
76
|
selfAliasesRef?: () => readonly string[]
|
|
76
77
|
credentialsStore?: LineCredentialStore
|
|
77
78
|
client?: LineClient
|
|
79
|
+
clientFactory?: (credManager?: LineCredentialManager) => LineClient
|
|
78
80
|
listenerFactory?: (client: LineClient) => LineListener
|
|
79
81
|
}
|
|
80
82
|
|
|
@@ -163,7 +165,15 @@ function clampLimit(requested: number, max: number): number {
|
|
|
163
165
|
|
|
164
166
|
export function createLineAdapter(options: LineAdapterOptions): LineAdapter {
|
|
165
167
|
const logger = options.logger ?? consoleLogger
|
|
166
|
-
|
|
168
|
+
// LineListener.connect() re-calls client.login() with NO arguments on every
|
|
169
|
+
// (re)connect, which resolves credentials via the client's credential manager
|
|
170
|
+
// rather than the explicit account start() passes. Wire the secrets.json store
|
|
171
|
+
// in as that manager (same structural cast as src/init/line-auth.ts) so the
|
|
172
|
+
// reconnect path doesn't fall through to the SDK's default file-based manager
|
|
173
|
+
// and loop forever on "No account found".
|
|
174
|
+
const credManager = options.credentialsStore as unknown as LineCredentialManager | undefined
|
|
175
|
+
const buildClient = options.clientFactory ?? ((cm?: LineCredentialManager) => new LineClient(cm))
|
|
176
|
+
const client = options.client ?? buildClient(credManager)
|
|
167
177
|
let listener: LineListener | null = null
|
|
168
178
|
let selfUserId: string | null = null
|
|
169
179
|
let connected = false
|
package/src/cli/channel.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { cancel, confirm, intro, isCancel, log, note, password, select, spinner,
|
|
|
4
4
|
import { defineCommand } from 'citty'
|
|
5
5
|
|
|
6
6
|
import { config } from '@/config'
|
|
7
|
+
import {
|
|
8
|
+
listChannels,
|
|
9
|
+
removeChannel,
|
|
10
|
+
type ChannelListEntry,
|
|
11
|
+
type GithubConfigCleanup,
|
|
12
|
+
} from '@/config/channels-mutation'
|
|
7
13
|
import { start, status, stop } from '@/container'
|
|
8
14
|
import {
|
|
9
15
|
CHANNEL_KINDS,
|
|
@@ -30,7 +36,7 @@ import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
|
30
36
|
|
|
31
37
|
import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
|
|
32
38
|
import { displayQR } from './qr'
|
|
33
|
-
import { c, done, errorLine, printDiscordInviteHint, printSlackAppManifestSetup } from './ui'
|
|
39
|
+
import { c, done, errorLine, printDiscordInviteHint, printSlackAppManifestSetup, successLine } from './ui'
|
|
34
40
|
|
|
35
41
|
const CHANNEL_LABELS: Record<ChannelKind, string> = {
|
|
36
42
|
'slack-bot': 'Slack',
|
|
@@ -185,6 +191,95 @@ const reauthSub = defineCommand({
|
|
|
185
191
|
},
|
|
186
192
|
})
|
|
187
193
|
|
|
194
|
+
const listSub = defineCommand({
|
|
195
|
+
meta: {
|
|
196
|
+
name: 'list',
|
|
197
|
+
description: 'list channel adapters configured for this agent',
|
|
198
|
+
},
|
|
199
|
+
args: {
|
|
200
|
+
json: {
|
|
201
|
+
type: 'boolean',
|
|
202
|
+
description: 'emit channels as JSON',
|
|
203
|
+
default: false,
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
async run({ args }) {
|
|
207
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
208
|
+
if (!isInitialized(cwd)) {
|
|
209
|
+
console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
|
|
210
|
+
process.exit(1)
|
|
211
|
+
}
|
|
212
|
+
const channels = listChannels(cwd)
|
|
213
|
+
if (args.json) {
|
|
214
|
+
process.stdout.write(`${JSON.stringify({ channels }, null, 2)}\n`)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
process.stdout.write(`${formatChannelList(channels)}\n`)
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const removeSub = defineCommand({
|
|
222
|
+
meta: {
|
|
223
|
+
name: 'remove',
|
|
224
|
+
description: 'remove a channel adapter from typeclaw.json and secrets.json',
|
|
225
|
+
},
|
|
226
|
+
args: {
|
|
227
|
+
adapter: {
|
|
228
|
+
type: 'positional',
|
|
229
|
+
description: `which adapter to remove (${CHANNEL_KINDS.join(' | ')}); omit to pick interactively`,
|
|
230
|
+
required: false,
|
|
231
|
+
},
|
|
232
|
+
yes: {
|
|
233
|
+
type: 'boolean',
|
|
234
|
+
description: 'skip the confirmation prompt',
|
|
235
|
+
default: false,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
async run({ args }) {
|
|
239
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
240
|
+
if (!isInitialized(cwd)) {
|
|
241
|
+
console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
|
|
242
|
+
process.exit(1)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const present = presentChannels(listChannels(cwd))
|
|
246
|
+
if (present.length === 0) {
|
|
247
|
+
console.error(errorLine('No channels are configured. Nothing to remove.'))
|
|
248
|
+
process.exit(1)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const adapter =
|
|
252
|
+
args.adapter === undefined ? await pickChannelToRemove(present) : validateRemoveAdapterArg(args.adapter, present)
|
|
253
|
+
|
|
254
|
+
if (args.yes !== true) {
|
|
255
|
+
const confirmed = await confirm({
|
|
256
|
+
message: `Remove the ${CHANNEL_LABELS[adapter]} channel? This deletes its config and credentials.`,
|
|
257
|
+
initialValue: false,
|
|
258
|
+
})
|
|
259
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
260
|
+
cancel('Aborted.')
|
|
261
|
+
process.exit(0)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const result = removeChannel(cwd, adapter)
|
|
266
|
+
if (!result.ok) {
|
|
267
|
+
console.error(errorLine(result.reason))
|
|
268
|
+
process.exit(1)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
process.stdout.write(`${successLine(`Removed ${CHANNEL_LABELS[adapter]} channel.`)}\n`)
|
|
272
|
+
if (result.githubCleanup !== undefined) printGithubCleanup(result.githubCleanup)
|
|
273
|
+
if (result.hadRemoteWebhooks) {
|
|
274
|
+
log.warn(
|
|
275
|
+
'GitHub webhooks registered on your repositories were NOT deleted. Remove them by hand at https://github.com/<owner>/<repo>/settings/hooks.',
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await maybePromptRestart(cwd, adapter, 'removed')
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
|
|
188
283
|
export const channelCommand = defineCommand({
|
|
189
284
|
meta: {
|
|
190
285
|
name: 'channel',
|
|
@@ -194,9 +289,92 @@ export const channelCommand = defineCommand({
|
|
|
194
289
|
add: addSub,
|
|
195
290
|
set: setSub,
|
|
196
291
|
reauth: reauthSub,
|
|
292
|
+
list: listSub,
|
|
293
|
+
remove: removeSub,
|
|
197
294
|
},
|
|
198
295
|
})
|
|
199
296
|
|
|
297
|
+
function presentChannels(entries: ChannelListEntry[]): ChannelKind[] {
|
|
298
|
+
return entries.map((entry) => entry.kind)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function pickChannelToRemove(present: ChannelKind[]): Promise<ChannelKind> {
|
|
302
|
+
if (present.length === 1) return present[0]!
|
|
303
|
+
const selected = await select<ChannelKind>({
|
|
304
|
+
message: 'Pick a channel to remove',
|
|
305
|
+
options: present.map((kind) => ({ value: kind, label: CHANNEL_LABELS[kind] })),
|
|
306
|
+
initialValue: present[0],
|
|
307
|
+
})
|
|
308
|
+
if (isCancel(selected)) {
|
|
309
|
+
cancel('Aborted.')
|
|
310
|
+
process.exit(0)
|
|
311
|
+
}
|
|
312
|
+
return selected
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function validateRemoveAdapterArg(adapter: string, present: ChannelKind[]): ChannelKind {
|
|
316
|
+
if (!isChannelKind(adapter)) {
|
|
317
|
+
console.error(errorLine(`Unknown adapter "${adapter}". Expected one of: ${CHANNEL_KINDS.join(', ')}.`))
|
|
318
|
+
process.exit(1)
|
|
319
|
+
}
|
|
320
|
+
if (!present.includes(adapter)) {
|
|
321
|
+
console.error(
|
|
322
|
+
errorLine(
|
|
323
|
+
`${CHANNEL_LABELS[adapter]} ("${adapter}") is not configured. Run \`typeclaw channel list\` to see what is.`,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
process.exit(1)
|
|
327
|
+
}
|
|
328
|
+
return adapter
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function formatChannelList(channels: ChannelListEntry[]): string {
|
|
332
|
+
if (channels.length === 0) return c.dim('No channels configured.')
|
|
333
|
+
|
|
334
|
+
const kindWidth = Math.max(4, ...channels.map((ch) => ch.kind.length))
|
|
335
|
+
const statusWidth = Math.max(6, ...channels.map((ch) => channelStatusText(ch).length))
|
|
336
|
+
const lines: string[] = []
|
|
337
|
+
lines.push(c.dim(`${'KIND'.padEnd(kindWidth)} ${'STATUS'.padEnd(statusWidth)} DETAIL`))
|
|
338
|
+
for (const ch of channels) {
|
|
339
|
+
const statusText = channelStatusText(ch)
|
|
340
|
+
const status = channelStatusOk(ch)
|
|
341
|
+
? c.green(statusText.padEnd(statusWidth))
|
|
342
|
+
: c.yellow(statusText.padEnd(statusWidth))
|
|
343
|
+
const detail = ch.detail ?? ''
|
|
344
|
+
lines.push(`${ch.kind.padEnd(kindWidth)} ${status} ${c.dim(detail)}`)
|
|
345
|
+
}
|
|
346
|
+
return lines.join('\n')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function channelStatusOk(ch: ChannelListEntry): boolean {
|
|
350
|
+
return ch.configured && ch.hasSecrets && ch.enabled
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function channelStatusText(ch: ChannelListEntry): string {
|
|
354
|
+
if (!ch.configured) return 'secrets-only'
|
|
355
|
+
if (!ch.hasSecrets) return 'no-secrets'
|
|
356
|
+
if (!ch.enabled) return 'disabled'
|
|
357
|
+
return 'ready'
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function printGithubCleanup(cleanup: GithubConfigCleanup): void {
|
|
361
|
+
const parts: string[] = []
|
|
362
|
+
if (cleanup.tunnelsRemoved > 0) {
|
|
363
|
+
parts.push(`removed ${cleanup.tunnelsRemoved} GitHub webhook tunnel${cleanup.tunnelsRemoved === 1 ? '' : 's'}`)
|
|
364
|
+
}
|
|
365
|
+
if (cleanup.matchRulesRemoved.length > 0) {
|
|
366
|
+
parts.push(
|
|
367
|
+
`removed ${cleanup.matchRulesRemoved.length} repo match rule${cleanup.matchRulesRemoved.length === 1 ? '' : 's'}`,
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
if (parts.length > 0) process.stdout.write(`${c.dim(`Also ${parts.join(', ')}.`)}\n`)
|
|
371
|
+
if (cleanup.matchRulesKept.length > 0) {
|
|
372
|
+
log.warn(
|
|
373
|
+
`Left ${cleanup.matchRulesKept.length} other \`github:\` match rule(s) in roles.member untouched: ${cleanup.matchRulesKept.join(', ')}.`,
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
200
378
|
async function resolveReauthableAdapter(
|
|
201
379
|
requested: string | undefined,
|
|
202
380
|
configured: Set<ChannelKind>,
|
|
@@ -1269,12 +1447,16 @@ function reportLineAuth(result: LineAuthResult): string {
|
|
|
1269
1447
|
return `LINE login failed: ${result.reason}`
|
|
1270
1448
|
}
|
|
1271
1449
|
|
|
1272
|
-
async function maybePromptRestart(
|
|
1450
|
+
async function maybePromptRestart(
|
|
1451
|
+
cwd: string,
|
|
1452
|
+
channel: ChannelKind,
|
|
1453
|
+
verb: 'added' | 'removed' = 'added',
|
|
1454
|
+
): Promise<void> {
|
|
1273
1455
|
const label = CHANNEL_LABELS[channel]
|
|
1274
1456
|
const current = await status({ cwd }).catch(() => null)
|
|
1275
1457
|
if (current === null || current.kind !== 'running') {
|
|
1276
1458
|
done({
|
|
1277
|
-
title: c.green(`${label} channel
|
|
1459
|
+
title: c.green(`${label} channel ${verb}.`),
|
|
1278
1460
|
hints: [
|
|
1279
1461
|
{ label: 'Start the agent:', command: 'typeclaw start' },
|
|
1280
1462
|
{ label: 'Then check status:', command: 'typeclaw status' },
|
|
@@ -1284,13 +1466,12 @@ async function maybePromptRestart(cwd: string, channel: ChannelKind): Promise<vo
|
|
|
1284
1466
|
}
|
|
1285
1467
|
|
|
1286
1468
|
const restartNow = await confirm({
|
|
1287
|
-
message:
|
|
1288
|
-
'Channel config is restart-required and the agent container is running. Restart it now to apply the new channel?',
|
|
1469
|
+
message: `Channel config is restart-required and the agent container is running. Restart it now to apply the ${verb} channel?`,
|
|
1289
1470
|
initialValue: true,
|
|
1290
1471
|
})
|
|
1291
1472
|
if (isCancel(restartNow) || !restartNow) {
|
|
1292
1473
|
done({
|
|
1293
|
-
title: c.green(`${label} channel
|
|
1474
|
+
title: c.green(`${label} channel ${verb}.`),
|
|
1294
1475
|
hints: [
|
|
1295
1476
|
{ label: 'Apply later:', command: 'typeclaw restart' },
|
|
1296
1477
|
{ label: 'Check status:', command: 'typeclaw status' },
|
|
@@ -1310,7 +1491,9 @@ async function maybePromptRestart(cwd: string, channel: ChannelKind): Promise<vo
|
|
|
1310
1491
|
process.exit(1)
|
|
1311
1492
|
}
|
|
1312
1493
|
done({
|
|
1313
|
-
title: c.green(
|
|
1494
|
+
title: c.green(
|
|
1495
|
+
`${label} channel ${verb}. Restarted ${started.plan.containerName} on host port ${started.hostPort}.`,
|
|
1496
|
+
),
|
|
1314
1497
|
hints: [
|
|
1315
1498
|
{ label: 'Attach TUI:', command: 'typeclaw tui' },
|
|
1316
1499
|
{ label: 'Follow logs:', command: 'typeclaw logs -f' },
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Readable, Writable } from 'node:stream'
|
|
2
|
+
import { styleText } from 'node:util'
|
|
3
|
+
|
|
4
|
+
import { SelectPrompt, settings } from '@clack/core'
|
|
5
|
+
import { limitOptions, S_BAR, S_BAR_END, S_RADIO_ACTIVE, S_RADIO_INACTIVE, symbolBar } from '@clack/prompts'
|
|
6
|
+
|
|
7
|
+
export type RefreshableOption<Value> = { value: Value; label: string; hint?: string; disabled?: boolean }
|
|
8
|
+
|
|
9
|
+
export type RefreshableSelectResult<Value> =
|
|
10
|
+
| { kind: 'picked'; value: Value }
|
|
11
|
+
| { kind: 'cancelled' }
|
|
12
|
+
| { kind: 'refresh'; highlightValue: Value }
|
|
13
|
+
|
|
14
|
+
export type RefreshableSelectOptions<Value> = {
|
|
15
|
+
message: string
|
|
16
|
+
options: RefreshableOption<Value>[]
|
|
17
|
+
initialValue?: Value
|
|
18
|
+
maxItems?: number
|
|
19
|
+
input?: Readable
|
|
20
|
+
output?: Writable
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const REFRESH_KEY = 'r'
|
|
24
|
+
|
|
25
|
+
// The cursor's value, falling back to the first row so a refresh on an empty
|
|
26
|
+
// cursor still carries a stable highlight.
|
|
27
|
+
export function highlightAt<Value>(options: RefreshableOption<Value>[], cursor: number): Value | undefined {
|
|
28
|
+
return options[cursor]?.value ?? options[0]?.value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// `refreshed` distinguishes an `r`-triggered abort (which also resolves to the
|
|
32
|
+
// cancel symbol) from a genuine ESC/Ctrl-C cancel.
|
|
33
|
+
export function toSelectResult<Value>(
|
|
34
|
+
result: Value | symbol,
|
|
35
|
+
refresh: { refreshed: boolean; highlightValue?: Value },
|
|
36
|
+
): RefreshableSelectResult<Value> {
|
|
37
|
+
if (refresh.refreshed) return { kind: 'refresh', highlightValue: refresh.highlightValue as Value }
|
|
38
|
+
if (typeof result === 'symbol') return { kind: 'cancelled' }
|
|
39
|
+
return { kind: 'picked', value: result }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A drop-in `select` that adds an `r`-to-refresh affordance. `@clack/prompts`'s
|
|
43
|
+
// `select` hides its prompt instance, so it can't expose the `key` event or the
|
|
44
|
+
// live cursor; dropping to `@clack/core`'s SelectPrompt is the only seam that
|
|
45
|
+
// can observe `r` without racing clack's own raw-mode stdin handling. The render
|
|
46
|
+
// below is ported from `@clack/prompts`'s select so the picker looks identical.
|
|
47
|
+
export async function refreshableSelect<Value>(
|
|
48
|
+
opts: RefreshableSelectOptions<Value>,
|
|
49
|
+
): Promise<RefreshableSelectResult<Value>> {
|
|
50
|
+
const optionsList = opts.options
|
|
51
|
+
const refreshController = new AbortController()
|
|
52
|
+
const refresh: { refreshed: boolean; highlightValue?: Value } = { refreshed: false }
|
|
53
|
+
|
|
54
|
+
const prompt = new SelectPrompt<RefreshableOption<Value>>({
|
|
55
|
+
options: optionsList,
|
|
56
|
+
initialValue: opts.initialValue,
|
|
57
|
+
signal: refreshController.signal,
|
|
58
|
+
...(opts.input !== undefined ? { input: opts.input } : {}),
|
|
59
|
+
...(opts.output !== undefined ? { output: opts.output } : {}),
|
|
60
|
+
render() {
|
|
61
|
+
return renderSelect(this as SelectPrompt<RefreshableOption<Value>>, opts.message, {
|
|
62
|
+
...(opts.maxItems !== undefined ? { maxItems: opts.maxItems } : {}),
|
|
63
|
+
...(opts.output !== undefined ? { output: opts.output } : {}),
|
|
64
|
+
})
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
prompt.on('key', (char) => {
|
|
69
|
+
if (char !== REFRESH_KEY) return
|
|
70
|
+
refresh.refreshed = true
|
|
71
|
+
refresh.highlightValue = highlightAt(optionsList, prompt.cursor)
|
|
72
|
+
// Reuse the prompt's own signal-abort cancel path: it sets state to cancel
|
|
73
|
+
// and runs close() (raw-mode + listener teardown), resolving `.prompt()`.
|
|
74
|
+
refreshController.abort()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const result = (await prompt.prompt()) as Value | symbol
|
|
78
|
+
return toSelectResult(result, refresh)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderSelect<Value>(
|
|
82
|
+
prompt: SelectPrompt<RefreshableOption<Value>>,
|
|
83
|
+
message: string,
|
|
84
|
+
limits: { maxItems?: number; output?: Writable },
|
|
85
|
+
): string {
|
|
86
|
+
const hasGuide = settings.withGuide
|
|
87
|
+
const titlePrefixBar = `${symbolBar(prompt.state)} `
|
|
88
|
+
const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${titlePrefixBar}${message}\n`
|
|
89
|
+
|
|
90
|
+
if (prompt.state === 'submit' || prompt.state === 'cancel') {
|
|
91
|
+
const closePrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : ''
|
|
92
|
+
const label = prompt.options[prompt.cursor]?.label ?? ''
|
|
93
|
+
const closed = prompt.state === 'cancel' ? styleText(['strikethrough', 'dim'], label) : styleText('dim', label)
|
|
94
|
+
const tail = prompt.state === 'cancel' && hasGuide ? `\n${styleText('gray', S_BAR)}` : ''
|
|
95
|
+
return `${title}${closePrefix}${closed}${tail}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const prefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''
|
|
99
|
+
const prefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : ''
|
|
100
|
+
const rendered = limitOptions({
|
|
101
|
+
cursor: prompt.cursor,
|
|
102
|
+
options: prompt.options,
|
|
103
|
+
...(limits.maxItems !== undefined ? { maxItems: limits.maxItems } : {}),
|
|
104
|
+
...(limits.output !== undefined ? { output: limits.output } : {}),
|
|
105
|
+
style: (item, active) => optionLine(item, item.disabled === true ? 'disabled' : active ? 'active' : 'inactive'),
|
|
106
|
+
}).join(`\n${prefix}`)
|
|
107
|
+
return `${title}${prefix}${rendered}\n${prefixEnd}\n`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function optionLine<Value>(option: RefreshableOption<Value>, state: 'inactive' | 'active' | 'disabled'): string {
|
|
111
|
+
const { label } = option
|
|
112
|
+
if (state === 'disabled') {
|
|
113
|
+
const hint = option.hint !== undefined ? ` ${styleText('dim', `(${option.hint})`)}` : ''
|
|
114
|
+
return `${styleText('gray', S_RADIO_INACTIVE)} ${styleText('gray', label)}${hint}`
|
|
115
|
+
}
|
|
116
|
+
if (state === 'active') {
|
|
117
|
+
const hint = option.hint !== undefined ? ` ${styleText('dim', `(${option.hint})`)}` : ''
|
|
118
|
+
return `${styleText('green', S_RADIO_ACTIVE)} ${label}${hint}`
|
|
119
|
+
}
|
|
120
|
+
return `${styleText('dim', S_RADIO_INACTIVE)} ${styleText('dim', label)}`
|
|
121
|
+
}
|