pi-code 0.1.0 → 0.2.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.
@@ -134,7 +134,7 @@ class TodoOverlay {
134
134
  private uiCtx?: ExtensionUIContext
135
135
  private widgetRegistered = false
136
136
  private tui?: TUI
137
- private getTodos: () => Todo[]
137
+ private readonly getTodos: () => Todo[]
138
138
 
139
139
  constructor(getTodos: () => Todo[]) {
140
140
  this.getTodos = getTodos
@@ -225,9 +225,9 @@ class TodoOverlay {
225
225
  * UI component for the /todos command
226
226
  */
227
227
  class TodoListComponent {
228
- private todos: Todo[]
229
- private theme: Theme
230
- private onClose: () => void
228
+ private readonly todos: Todo[]
229
+ private readonly theme: Theme
230
+ private readonly onClose: () => void
231
231
  private cachedWidth?: number
232
232
  private cachedLines?: string[]
233
233
 
@@ -254,15 +254,14 @@ class TodoListComponent {
254
254
  lines.push('')
255
255
  const title = th.fg('accent', ' Todos ')
256
256
  const headerLine = th.fg('borderMuted', '─'.repeat(3)) + title + th.fg('borderMuted', '─'.repeat(Math.max(0, width - 10)))
257
- lines.push(truncateToWidth(headerLine, width))
258
- lines.push('')
257
+ lines.push(truncateToWidth(headerLine, width), '')
259
258
 
260
259
  if (this.todos.length === 0) {
261
260
  lines.push(truncateToWidth(` ${th.fg('dim', 'No todos yet. Ask the agent to add some!')}`, width))
262
261
  } else {
263
262
  const completed = this.todos.filter((t) => t.status === 'completed').length
264
- lines.push(truncateToWidth(` ${th.fg('muted', `${completed}/${this.todos.length} completed`)}`, width))
265
- lines.push('')
263
+ const completedLabel = `${completed}/${this.todos.length} completed`
264
+ lines.push(truncateToWidth(` ${th.fg('muted', completedLabel)}`, width), '')
266
265
 
267
266
  for (const todo of this.todos) {
268
267
  const glyph = statusGlyph(todo.status, th)
@@ -270,15 +269,14 @@ class TodoListComponent {
270
269
  const text = todo.status === 'completed' ? th.fg('dim', todo.text) : th.fg('text', todo.text)
271
270
  let line = ` ${glyph} ${id} ${text}`
272
271
  if (todo.status === 'in_progress' && todo.activeForm) {
273
- line += ` ${th.fg('dim', `(${todo.activeForm})`)}`
272
+ const activeFormLabel = `(${todo.activeForm})`
273
+ line += ` ${th.fg('dim', activeFormLabel)}`
274
274
  }
275
275
  lines.push(truncateToWidth(line, width))
276
276
  }
277
277
  }
278
278
 
279
- lines.push('')
280
- lines.push(truncateToWidth(` ${th.fg('dim', 'Press Escape to close')}`, width))
281
- lines.push('')
279
+ lines.push('', truncateToWidth(` ${th.fg('dim', 'Press Escape to close')}`, width), '')
282
280
 
283
281
  this.cachedWidth = width
284
282
  this.cachedLines = lines
@@ -296,7 +294,27 @@ class TodoListComponent {
296
294
  // genuine replay bugs still propagate instead of being silently swallowed.
297
295
  const isStaleCtxError = (e: unknown): boolean => /stale after session replacement/.test(String(e))
298
296
 
299
- export default function (pi: ExtensionAPI) {
297
+ const LIST_RESULT_PREVIEW = 5
298
+
299
+ const renderTodoListResult = (todoList: Todo[], expanded: boolean, theme: Theme): Text => {
300
+ if (todoList.length === 0) {
301
+ return new Text(theme.fg('dim', 'No todos'), 0, 0)
302
+ }
303
+ let listText = theme.fg('muted', `${todoList.length} todo(s):`)
304
+ const display = expanded ? todoList : todoList.slice(0, LIST_RESULT_PREVIEW)
305
+ for (const t of display) {
306
+ const idLabel = `#${t.id}`
307
+ const itemText = t.status === 'completed' ? theme.fg('dim', t.text) : theme.fg('muted', t.text)
308
+ listText += `\n${statusGlyph(t.status, theme)} ${theme.fg('accent', idLabel)} ${itemText}`
309
+ }
310
+ if (!expanded && todoList.length > LIST_RESULT_PREVIEW) {
311
+ const moreLabel = `... ${todoList.length - LIST_RESULT_PREVIEW} more`
312
+ listText += `\n${theme.fg('dim', moreLabel)}`
313
+ }
314
+ return new Text(listText, 0, 0)
315
+ }
316
+
317
+ export default function todoExtension(pi: ExtensionAPI) {
300
318
  // In-memory state (reconstructed from session on load)
301
319
  let todos: Todo[] = []
302
320
  let nextId = 1
@@ -382,7 +400,10 @@ export default function (pi: ExtensionAPI) {
382
400
  todo.status = 'in_progress'
383
401
  if (params.activeForm) todo.activeForm = params.activeForm
384
402
  let text = `Started #${todo.id}: ${todo.text}`
385
- if (demoted.length > 0) text += ` (moved ${demoted.map((t) => `#${t.id}`).join(', ')} back to pending)`
403
+ if (demoted.length > 0) {
404
+ const movedIds = demoted.map((t) => `#${t.id}`).join(', ')
405
+ text += ` (moved ${movedIds} back to pending)`
406
+ }
386
407
  return ok('start', text)
387
408
  }
388
409
 
@@ -446,9 +467,18 @@ export default function (pi: ExtensionAPI) {
446
467
 
447
468
  renderCall(args, theme, _context) {
448
469
  let text = theme.fg('toolTitle', theme.bold('todo ')) + theme.fg('muted', args.action)
449
- if (args.id !== undefined) text += ` ${theme.fg('accent', `#${args.id}`)}`
450
- if (args.text) text += ` ${theme.fg('dim', `"${args.text}"`)}`
451
- if (args.activeForm) text += ` ${theme.fg('dim', `(${args.activeForm})`)}`
470
+ if (args.id !== undefined) {
471
+ const idLabel = `#${args.id}`
472
+ text += ` ${theme.fg('accent', idLabel)}`
473
+ }
474
+ if (args.text) {
475
+ const textLabel = `"${args.text}"`
476
+ text += ` ${theme.fg('dim', textLabel)}`
477
+ }
478
+ if (args.activeForm) {
479
+ const activeFormLabel = `(${args.activeForm})`
480
+ text += ` ${theme.fg('dim', activeFormLabel)}`
481
+ }
452
482
  return new Text(text, 0, 0)
453
483
  },
454
484
 
@@ -464,20 +494,7 @@ export default function (pi: ExtensionAPI) {
464
494
  }
465
495
 
466
496
  if (details.action === 'list') {
467
- const todoList = details.todos
468
- if (todoList.length === 0) {
469
- return new Text(theme.fg('dim', 'No todos'), 0, 0)
470
- }
471
- let listText = theme.fg('muted', `${todoList.length} todo(s):`)
472
- const display = expanded ? todoList : todoList.slice(0, 5)
473
- for (const t of display) {
474
- const itemText = t.status === 'completed' ? theme.fg('dim', t.text) : theme.fg('muted', t.text)
475
- listText += `\n${statusGlyph(t.status, theme)} ${theme.fg('accent', `#${t.id}`)} ${itemText}`
476
- }
477
- if (!expanded && todoList.length > 5) {
478
- listText += `\n${theme.fg('dim', `... ${todoList.length - 5} more`)}`
479
- }
480
- return new Text(listText, 0, 0)
497
+ return renderTodoListResult(details.todos, expanded, theme)
481
498
  }
482
499
 
483
500
  const text = result.content[0]
package/extensions/web.ts CHANGED
@@ -32,14 +32,16 @@ export function decodeEntities(text: string): string {
32
32
  }
33
33
 
34
34
  export function stripTags(html: string): string {
35
- return decodeEntities(html.replace(/<[^>]*>/g, ''))
35
+ // [^<>] rather than [^>]: excluding `<` bounds a failed match at the next tag start
36
+ // instead of rescanning to end of input, which is what makes the strip linear.
37
+ return decodeEntities(html.replace(/<[^<>]*>/g, ''))
36
38
  .replace(/\s+/g, ' ')
37
39
  .trim()
38
40
  }
39
41
 
40
42
  /** Resolve DuckDuckGo's redirect links (/l/?uddg=<encoded>) to the target URL. */
41
43
  export function resolveResultUrl(href: string): string {
42
- const match = href.match(/[?&]uddg=([^&]+)/)
44
+ const match = /[?&]uddg=([^&]+)/.exec(href)
43
45
  if (match) {
44
46
  try {
45
47
  return decodeURIComponent(match[1])
@@ -72,35 +74,38 @@ export function htmlToText(html: string): string {
72
74
  .replace(/<script[\s\S]*?<\/script>/gi, ' ')
73
75
  .replace(/<style[\s\S]*?<\/style>/gi, ' ')
74
76
  .replace(/<(br|\/p|\/div|\/h[1-6]|\/li|\/tr)[^>]*>/gi, '\n')
75
- const text = decodeEntities(withoutBlocks.replace(/<[^>]*>/g, ' '))
77
+ const text = decodeEntities(withoutBlocks.replace(/<[^<>]*>/g, ' '))
76
78
  .replace(/[ \t]+/g, ' ')
77
79
  .replace(/\n\s+/g, '\n')
78
80
  .trim()
79
81
  return text.length > MAX_FETCH_CHARS ? `${text.slice(0, MAX_FETCH_CHARS)}\n[truncated ${text.length - MAX_FETCH_CHARS} chars]` : text
80
82
  }
81
83
 
82
- /** SSRF guard: true for loopback, RFC1918, link-local, CGNAT, and private IPv6 ranges. Fails closed on unparseable input. */
83
- export function isPrivateAddress(ip: string): boolean {
84
- const addr = ip
85
- .toLowerCase()
86
- .replace(/^\[|\]$/g, '')
87
- .split('%')[0]
88
- if (addr.includes(':')) {
89
- // IPv4-mapped (::ffff:...) and NAT64 (64:ff9b::...) forms embed an IPv4 in the low 32 bits,
90
- // in either dotted-decimal or hex; the WHATWG URL parser normalizes literals to the hex form.
91
- const dotted = addr.match(/^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/)
92
- if (dotted) return isPrivateAddress(dotted[1])
93
- const hex = addr.match(/^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
94
- if (hex) {
95
- const hi = Number.parseInt(hex[1], 16)
96
- const lo = Number.parseInt(hex[2], 16)
97
- return isPrivateAddress(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`)
98
- }
99
- if (addr === '::1' || addr === '::') return true
100
- if (/^fe[89ab]/.test(addr)) return true // link-local fe80::/10
101
- if (/^f[cd]/.test(addr)) return true // unique local fc00::/7
102
- return false
103
- }
84
+ /**
85
+ * IPv4-mapped (::ffff:...) and NAT64 (64:ff9b::...) forms embed an IPv4 in the low 32 bits,
86
+ * in either dotted-decimal or hex; the WHATWG URL parser normalizes literals to the hex form.
87
+ */
88
+ function embeddedIpv4(addr: string): string | null {
89
+ const dotted = /^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/.exec(addr)
90
+ if (dotted) return dotted[1]
91
+ const hex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(addr)
92
+ if (!hex) return null
93
+ const hi = Number.parseInt(hex[1], 16)
94
+ const lo = Number.parseInt(hex[2], 16)
95
+ return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`
96
+ }
97
+
98
+ function isPrivateIpv6(addr: string): boolean {
99
+ const embedded = embeddedIpv4(addr)
100
+ if (embedded) return isPrivateAddress(embedded)
101
+ if (addr === '::1' || addr === '::') return true
102
+ if (/^fe[89ab]/.test(addr)) return true // link-local fe80::/10
103
+ if (/^fe[cdef]/.test(addr)) return true // site-local fec0::/10, deprecated but still routed
104
+ if (addr.startsWith('ff')) return true // multicast ff00::/8
105
+ return /^f[cd]/.test(addr) // unique local fc00::/7
106
+ }
107
+
108
+ function isPrivateIpv4(addr: string): boolean {
104
109
  const parts = addr.split('.').map(Number)
105
110
  if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return true
106
111
  const [a, b] = parts
@@ -108,13 +113,27 @@ export function isPrivateAddress(ip: string): boolean {
108
113
  if (a === 169 && b === 254) return true
109
114
  if (a === 172 && b >= 16 && b <= 31) return true
110
115
  if (a === 192 && b === 168) return true
111
- if (a === 100 && b >= 64 && b <= 127) return true
112
- return false
116
+ if (a === 192 && b === 0 && parts[2] === 0) return true // protocol assignments 192.0.0/24
117
+ if (a === 198 && (b === 18 || b === 19)) return true // benchmarking 198.18/15
118
+ if (a >= 224) return true // multicast 224/4, reserved 240/4, broadcast 255.255.255.255
119
+ return a === 100 && b >= 64 && b <= 127
120
+ }
121
+
122
+ /** SSRF guard: true for loopback, RFC1918, link-local, CGNAT, and private IPv6 ranges. Fails closed on unparseable input. */
123
+ export function isPrivateAddress(ip: string): boolean {
124
+ const addr = ip
125
+ .toLowerCase()
126
+ .replace(/^\[|\]$/g, '')
127
+ .split('%')[0]
128
+ return addr.includes(':') ? isPrivateIpv6(addr) : isPrivateIpv4(addr)
113
129
  }
114
130
 
115
131
  async function assertPublicHost(url: URL): Promise<void> {
116
132
  const host = url.hostname.replace(/^\[|\]$/g, '')
117
133
  const addresses = await lookup(host, { all: true, verbatim: true })
134
+ // An empty list would leave nothing for the loop to reject, so the guard would pass
135
+ // vacuously. Schemes without a host (data:, file:) reach here the same way.
136
+ if (addresses.length === 0) throw new Error(`${url.hostname || url.protocol} did not resolve to any address`)
118
137
  for (const { address } of addresses) {
119
138
  if (isPrivateAddress(address)) throw new Error(`refusing to fetch private/internal address for ${url.hostname} (${address})`)
120
139
  }
@@ -150,6 +169,9 @@ async function fetchText(rawUrl: string): Promise<{ text: string; contentType: s
150
169
  const location = response.headers.get('location')
151
170
  if (!location) throw new Error(`redirect without location from ${url.hostname}`)
152
171
  url = new URL(location, url)
172
+ // Only the caller's URL was scheme-checked; a redirect could hand back data: or
173
+ // file:, which carry no host for the address guard to inspect.
174
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error(`unsupported redirect scheme ${url.protocol} from ${rawUrl}`)
153
175
  continue
154
176
  }
155
177
  if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi-package"