@saifymatteo/pi-extensions 0.0.0-stage → 0.1.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.
Files changed (183) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +151 -2
  4. package/inventory.json +73 -0
  5. package/package.json +74 -4
  6. package/packages/pi-bg-bash/LICENSE +661 -0
  7. package/packages/pi-bg-bash/NOTICE.md +3 -0
  8. package/packages/pi-bg-bash/README.md +41 -0
  9. package/packages/pi-bg-bash/examples/settings.example.json +6 -0
  10. package/packages/pi-bg-bash/package.json +38 -0
  11. package/packages/pi-bg-bash/src/index.ts +969 -0
  12. package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
  13. package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
  14. package/packages/pi-claude-usage/LICENSE +661 -0
  15. package/packages/pi-claude-usage/NOTICE.md +3 -0
  16. package/packages/pi-claude-usage/README.md +27 -0
  17. package/packages/pi-claude-usage/package.json +35 -0
  18. package/packages/pi-claude-usage/src/index.ts +130 -0
  19. package/packages/pi-clipboard-image/LICENSE +661 -0
  20. package/packages/pi-clipboard-image/NOTICE.md +3 -0
  21. package/packages/pi-clipboard-image/README.md +27 -0
  22. package/packages/pi-clipboard-image/package.json +36 -0
  23. package/packages/pi-clipboard-image/src/index.ts +527 -0
  24. package/packages/pi-codeblock-borders/LICENSE +661 -0
  25. package/packages/pi-codeblock-borders/NOTICE.md +3 -0
  26. package/packages/pi-codeblock-borders/README.md +30 -0
  27. package/packages/pi-codeblock-borders/package.json +35 -0
  28. package/packages/pi-codeblock-borders/src/index.ts +302 -0
  29. package/packages/pi-codegraph/LICENSE +661 -0
  30. package/packages/pi-codegraph/NOTICE.md +5 -0
  31. package/packages/pi-codegraph/README.md +35 -0
  32. package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
  33. package/packages/pi-codegraph/package.json +37 -0
  34. package/packages/pi-codegraph/src/index.ts +573 -0
  35. package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
  36. package/packages/pi-compact-tools/LICENSE +661 -0
  37. package/packages/pi-compact-tools/NOTICE.md +3 -0
  38. package/packages/pi-compact-tools/README.md +27 -0
  39. package/packages/pi-compact-tools/package.json +36 -0
  40. package/packages/pi-compact-tools/src/index.ts +1161 -0
  41. package/packages/pi-decisions/LICENSE +661 -0
  42. package/packages/pi-decisions/NOTICE.md +3 -0
  43. package/packages/pi-decisions/README.md +37 -0
  44. package/packages/pi-decisions/examples/service-models.example.json +27 -0
  45. package/packages/pi-decisions/package.json +37 -0
  46. package/packages/pi-decisions/src/index.ts +364 -0
  47. package/packages/pi-decisions/src/lib/service-models.ts +95 -0
  48. package/packages/pi-decisions/test/service-models.test.mjs +71 -0
  49. package/packages/pi-load-pi-dotenv/LICENSE +661 -0
  50. package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
  51. package/packages/pi-load-pi-dotenv/README.md +27 -0
  52. package/packages/pi-load-pi-dotenv/package.json +32 -0
  53. package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
  54. package/packages/pi-model-go/LICENSE +661 -0
  55. package/packages/pi-model-go/NOTICE.md +5 -0
  56. package/packages/pi-model-go/README.md +33 -0
  57. package/packages/pi-model-go/package.json +36 -0
  58. package/packages/pi-model-go/src/index.ts +171 -0
  59. package/packages/pi-model-go/src/lib/dialog.ts +31 -0
  60. package/packages/pi-model-go/src/lib/model-go.ts +239 -0
  61. package/packages/pi-model-go/test/behavior.test.mjs +17 -0
  62. package/packages/pi-prestart-working/LICENSE +661 -0
  63. package/packages/pi-prestart-working/NOTICE.md +3 -0
  64. package/packages/pi-prestart-working/README.md +25 -0
  65. package/packages/pi-prestart-working/package.json +35 -0
  66. package/packages/pi-prestart-working/src/index.ts +86 -0
  67. package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
  68. package/packages/pi-root-guard/LICENSE +661 -0
  69. package/packages/pi-root-guard/NOTICE.md +3 -0
  70. package/packages/pi-root-guard/README.md +27 -0
  71. package/packages/pi-root-guard/package.json +32 -0
  72. package/packages/pi-root-guard/src/index.ts +202 -0
  73. package/packages/pi-session-finder/LICENSE +661 -0
  74. package/packages/pi-session-finder/NOTICE.md +3 -0
  75. package/packages/pi-session-finder/README.md +33 -0
  76. package/packages/pi-session-finder/package.json +36 -0
  77. package/packages/pi-session-finder/src/index.ts +306 -0
  78. package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
  79. package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
  80. package/packages/pi-session-id/LICENSE +661 -0
  81. package/packages/pi-session-id/NOTICE.md +3 -0
  82. package/packages/pi-session-id/README.md +31 -0
  83. package/packages/pi-session-id/package.json +35 -0
  84. package/packages/pi-session-id/src/index.ts +20 -0
  85. package/packages/pi-session-id/test/behavior.test.mjs +25 -0
  86. package/packages/pi-session-nav/LICENSE +661 -0
  87. package/packages/pi-session-nav/NOTICE.md +3 -0
  88. package/packages/pi-session-nav/README.md +32 -0
  89. package/packages/pi-session-nav/package.json +36 -0
  90. package/packages/pi-session-nav/src/index.ts +203 -0
  91. package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
  92. package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
  93. package/packages/pi-session-rename/LICENSE +661 -0
  94. package/packages/pi-session-rename/NOTICE.md +3 -0
  95. package/packages/pi-session-rename/README.md +31 -0
  96. package/packages/pi-session-rename/package.json +35 -0
  97. package/packages/pi-session-rename/src/index.ts +456 -0
  98. package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
  99. package/packages/pi-session-retention/LICENSE +661 -0
  100. package/packages/pi-session-retention/NOTICE.md +3 -0
  101. package/packages/pi-session-retention/README.md +62 -0
  102. package/packages/pi-session-retention/package.json +36 -0
  103. package/packages/pi-session-retention/src/README.md +42 -0
  104. package/packages/pi-session-retention/src/core.ts +333 -0
  105. package/packages/pi-session-retention/src/dialog.ts +118 -0
  106. package/packages/pi-session-retention/src/index.ts +311 -0
  107. package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
  108. package/packages/pi-session-retention/src/safety.ts +50 -0
  109. package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
  110. package/packages/pi-skill-usage-log/LICENSE +661 -0
  111. package/packages/pi-skill-usage-log/NOTICE.md +3 -0
  112. package/packages/pi-skill-usage-log/README.md +32 -0
  113. package/packages/pi-skill-usage-log/package.json +36 -0
  114. package/packages/pi-skill-usage-log/src/index.ts +316 -0
  115. package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
  116. package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
  117. package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
  118. package/packages/pi-tool-usage-log/LICENSE +661 -0
  119. package/packages/pi-tool-usage-log/NOTICE.md +5 -0
  120. package/packages/pi-tool-usage-log/README.md +32 -0
  121. package/packages/pi-tool-usage-log/package.json +36 -0
  122. package/packages/pi-tool-usage-log/src/index.ts +233 -0
  123. package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
  124. package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
  125. package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
  126. package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
  127. package/packages/pi-tts/LICENSE +661 -0
  128. package/packages/pi-tts/NOTICE.md +3 -0
  129. package/packages/pi-tts/README.md +37 -0
  130. package/packages/pi-tts/examples/service-models.example.json +27 -0
  131. package/packages/pi-tts/package.json +37 -0
  132. package/packages/pi-tts/src/index.ts +135 -0
  133. package/packages/pi-tts/src/lib/service-models.ts +95 -0
  134. package/packages/pi-tts/test/tts-output.test.mjs +47 -0
  135. package/packages/pi-tts/test/tts.test.mjs +57 -0
  136. package/packages/pi-user-image-previews/LICENSE +661 -0
  137. package/packages/pi-user-image-previews/NOTICE.md +3 -0
  138. package/packages/pi-user-image-previews/README.md +27 -0
  139. package/packages/pi-user-image-previews/package.json +36 -0
  140. package/packages/pi-user-image-previews/src/index.ts +168 -0
  141. package/packages/pi-vision/LICENSE +661 -0
  142. package/packages/pi-vision/NOTICE.md +5 -0
  143. package/packages/pi-vision/README.md +43 -0
  144. package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
  145. package/packages/pi-vision/package.json +38 -0
  146. package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
  147. package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
  148. package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
  149. package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
  150. package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
  151. package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
  152. package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
  153. package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
  154. package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
  155. package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
  156. package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
  157. package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
  158. package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
  159. package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
  160. package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
  161. package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
  162. package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
  163. package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
  164. package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
  165. package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  166. package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
  167. package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
  168. package/packages/pi-vision/src/index.ts +728 -0
  169. package/packages/pi-vision/test/vision.test.mjs +30 -0
  170. package/packages/pi-wait/LICENSE +661 -0
  171. package/packages/pi-wait/NOTICE.md +3 -0
  172. package/packages/pi-wait/README.md +37 -0
  173. package/packages/pi-wait/package.json +37 -0
  174. package/packages/pi-wait/src/index.ts +88 -0
  175. package/packages/pi-wait/src/lib/async-work.ts +27 -0
  176. package/packages/pi-wait/test/wait.test.mjs +43 -0
  177. package/packages/pi-zvec/LICENSE +661 -0
  178. package/packages/pi-zvec/NOTICE.md +3 -0
  179. package/packages/pi-zvec/README.md +37 -0
  180. package/packages/pi-zvec/examples/zvec.example.json +5 -0
  181. package/packages/pi-zvec/package.json +37 -0
  182. package/packages/pi-zvec/src/index.ts +754 -0
  183. package/packages/pi-zvec/test/zvec.test.mjs +19 -0
@@ -0,0 +1,37 @@
1
+ # @saifymatteo/pi-decisions
2
+
3
+ ## Purpose and registrations
4
+
5
+ When configured, registers classifier providers and the `classify` tool (choice/bool/score; JSON state and optional PNG/JPEG/WebP images). No slash commands. Requires Pi's classifier model registry APIs (`findOfType`, `classify`, provider `classifiers`); older hosts may not implement them.
6
+
7
+ ## Configuration and service
8
+
9
+ Reads the shared `<agent-dir>/extensions/service-models.json` registry and registers every model with `type: "classifier"`. A missing registry or no classifier model means no registration. Nothing is created automatically; opt in by manually creating the registry from `examples/service-models.example.json`. An optional one-line `description` per model is shown to the agent as a strength hint in the `model` parameter. Classifier models require `type: "classifier"`, `api: "openai-decisions"`, positive `contextWindow` and `input` capabilities.
10
+
11
+ Use an actual OpenAI Decisions-compatible service at your chosen base URL (POST `decisions` under the versioned base). Generic examples use localhost, not a configured personal server. API keys accept `$ENV` or `${ENV}`; if omitted, credentials fall back to agent `auth.json`. Prefer explicit environment references. No external CLI required.
12
+
13
+ ## Security and limits
14
+
15
+ Supplied state, questions, and image pixels are transmitted to the selected model service. Never send secrets or treat confidence as a security decision. 30s timeout, no retries/provider fallback; at most four images, 10 MiB each/20 MiB total. Large results use private temp files. Service responses become model-visible. Source caveat: the classifier rejects an empty API key despite helper support for keyless access; use a nonempty configured key. The adapter also uses default fetch redirect behavior; use trusted endpoints.
16
+
17
+ ## Tests
18
+
19
+ From the monorepo root with its existing test dependencies available:
20
+
21
+ ```sh
22
+ node --test packages/pi-decisions/test/*.test.mjs
23
+ ```
24
+
25
+ Tests use the shared test-only TypeScript/VM loader, fixtures, and host/service stubs. They do not prove compatibility with a live host, CLI, or model service.
26
+
27
+ ## Running locally
28
+
29
+ From the monorepo root:
30
+
31
+ ```sh
32
+ pi -e ./packages/pi-decisions
33
+ ```
34
+
35
+ Node >=24; host imports are peers, not bundled dependencies. Load only one copy: for an isolated session use `--no-extensions` with explicit `-e` paths so the original live extension is not also discovered. Set `PI_CODING_AGENT_DIR` to a disposable agent directory before launch; unset means the normal `~/.pi/agent` fallback. Extension config continues to resolve there, never relative to this package.
36
+
37
+ Examples are manually authored, sanitized reference documents, not runtime defaults. Review and manually create only the needed files in the isolated agent directory; do not copy live credentials or endpoints. No example is automatically loaded or copied. Loading an extension grants Pi-process OS permissions. Licensed AGPL-3.0-only; see [NOTICE.md](NOTICE.md).
@@ -0,0 +1,27 @@
1
+ {
2
+ "providers": {
3
+ "example-services": {
4
+ "baseUrl": "http://localhost:8000/v1",
5
+ "apiKey": "$PI_SERVICE_API_KEY",
6
+ "models": [
7
+ {
8
+ "id": "example-classifier",
9
+ "type": "classifier",
10
+ "api": "openai-decisions",
11
+ "contextWindow": 8192,
12
+ "input": [
13
+ "text",
14
+ "image"
15
+ ],
16
+ "description": "Example strength hint shown to the agent."
17
+ },
18
+ {
19
+ "id": "example-tts",
20
+ "type": "tts",
21
+ "api": "openai-audio-speech",
22
+ "description": "Example strength hint shown to the agent."
23
+ }
24
+ ]
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@saifymatteo/pi-decisions",
3
+ "version": "0.1.1",
4
+ "license": "AGPL-3.0-only",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "files": [
10
+ "src",
11
+ "examples",
12
+ "README.md",
13
+ "NOTICE.md",
14
+ "LICENSE"
15
+ ],
16
+ "pi": {
17
+ "extensions": [
18
+ "./src/index.ts"
19
+ ]
20
+ },
21
+ "peerDependencies": {
22
+ "@earendil-works/pi-coding-agent": "*",
23
+ "typebox": "*"
24
+ },
25
+ "engines": {
26
+ "node": ">=24"
27
+ },
28
+ "description": "Agent-facing classify tool backed by configured OpenAI Decisions endpoints.",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/saifymatteo/pi-extensions.git",
35
+ "directory": "packages/pi-decisions"
36
+ }
37
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * decisions.ts — Configured classifier providers and the agent-facing classify tool
3
+ *
4
+ * Sends OpenAI Decisions API requests to compatible endpoints such as oMLX's
5
+ * /v1/decisions endpoint.
6
+ */
7
+
8
+ import { constants } from 'node:fs'
9
+ import { loadServiceConfig, configuredApiKey, serviceApiKey, modelOptions } from './lib/service-models'
10
+ import { open, stat, mkdtemp, writeFile } from 'node:fs/promises'
11
+ import { join, resolve } from 'node:path'
12
+ import { tmpdir } from 'node:os'
13
+ import { truncateHead, type ExtensionAPI } from '@earendil-works/pi-coding-agent'
14
+ import { Type } from 'typebox'
15
+
16
+ const textSchema = Type.String({ minLength: 1, maxLength: 8192 })
17
+ const questionSchema = Type.Union([
18
+ Type.Object({ type: Type.Literal('choice'), instructions: textSchema,
19
+ criteria: Type.Record(Type.String({ minLength: 1, maxLength: 128 }), textSchema, { minProperties: 2, maxProperties: 64 }) }, { additionalProperties: false }),
20
+ Type.Object({ type: Type.Literal('bool'), instructions: textSchema,
21
+ criteria: Type.Object({ true: textSchema, false: textSchema }, { additionalProperties: false }) }, { additionalProperties: false }),
22
+ Type.Object({ type: Type.Literal('score'), instructions: textSchema,
23
+ criteria: Type.Array(textSchema, { minItems: 2, maxItems: 10 }) }, { additionalProperties: false }),
24
+ ])
25
+
26
+ function validateQuestions(questions: Record<string, any>) {
27
+ if (!questions || typeof questions !== 'object' || Array.isArray(questions) ||
28
+ Object.keys(questions).length < 1 || Object.keys(questions).length > 32) {
29
+ throw new Error('classify requires 1..32 named questions')
30
+ }
31
+ const text = (value: unknown) => typeof value === 'string' && value.trim().length > 0 && value.length <= 8192
32
+ for (const [name, q] of Object.entries(questions)) {
33
+ if (!name || name.length > 128 || !q || !text(q.instructions)) throw new Error(`Invalid instructions for question ${name}`)
34
+ const criteria = q.criteria
35
+ const labels = criteria && typeof criteria === 'object' ? Object.keys(criteria) : []
36
+ if (q.type === 'choice' && criteria && !Array.isArray(criteria) && labels.length >= 2 && labels.length <= 64 &&
37
+ labels.every(label => label.length > 0 && label.length <= 128 && text(criteria[label]))) continue
38
+ if (q.type === 'bool' && criteria && !Array.isArray(criteria) && labels.length === 2 && text(criteria.true) && text(criteria.false)) continue
39
+ if (q.type === 'score' && Array.isArray(criteria) && criteria.length >= 2 && criteria.length <= 10 && criteria.every(text)) continue
40
+ throw new Error(`Invalid type or criteria for question ${name}`)
41
+ }
42
+ }
43
+
44
+ function validateAnswers(answers: Record<string, any>, questions: Record<string, any>) {
45
+ const range = (value: unknown, max = 1) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= max
46
+ if (!answers || Object.keys(answers).length !== Object.keys(questions).length ||
47
+ Object.keys(answers).some(name => !Object.hasOwn(questions, name))) throw new Error('Unexpected classifier answer keys')
48
+ for (const [name, q] of Object.entries(questions)) {
49
+ const answer = answers?.[name]
50
+ let valid = answer?.type === q.type
51
+ if (q.type === 'choice') {
52
+ const labels = Object.keys(q.criteria)
53
+ const probabilities: Record<string, number> = answer?.probabilities ?? {}
54
+ valid &&= labels.includes(answer.choice) && Object.keys(probabilities).length === labels.length &&
55
+ labels.every(label => range(probabilities[label])) &&
56
+ Math.abs(Object.values(probabilities).reduce((sum: number, p: any) => sum + p, 0) - 1) <= 0.02 && range(answer.confidence)
57
+ } else if (q.type === 'score') {
58
+ valid &&= range(answer.score, q.criteria.length - 1) && range(answer.confidence)
59
+ } else {
60
+ valid &&= range(answer.probability)
61
+ }
62
+ if (!valid) throw new Error(`Invalid classifier answer for ${name}`)
63
+ }
64
+ }
65
+
66
+ async function loadImages(paths: string[], cwd: string, signal?: AbortSignal) {
67
+ if (!Array.isArray(paths) || paths.length > 4) throw new Error('classify accepts at most 4 local images')
68
+ const images: { type: 'image'; data: string; mimeType: string }[] = []
69
+ let total = 0
70
+ for (const path of paths) {
71
+ signal?.throwIfAborted()
72
+ if (typeof path !== 'string' || !path || /^(https?:|data:)/i.test(path)) throw new Error('images must be local file paths')
73
+ const resolved = resolve(cwd, path.startsWith('~/') ? join(process.env.HOME || process.env.USERPROFILE || '', path.slice(2)) : path)
74
+ const info = await stat(resolved)
75
+ if (!info.isFile() || info.size > 10 * 1024 * 1024) throw new Error('Each image must be a regular file of at most 10 MiB')
76
+ // On POSIX, nonblocking open also protects against a file being swapped for a FIFO after stat.
77
+ const file = await open(resolved, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0))
78
+ try {
79
+ const stat = await file.stat()
80
+ if (!stat.isFile() || stat.size > 10 * 1024 * 1024) throw new Error('Each image must be a regular file of at most 10 MiB')
81
+ const bytes = await file.readFile()
82
+ total += bytes.length
83
+ if (bytes.length > 10 * 1024 * 1024 || total > 20 * 1024 * 1024) throw new Error('Image input exceeds the 10 MiB per-file or 20 MiB total limit')
84
+ const mimeType = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ? 'image/png'
85
+ : bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 ? 'image/jpeg'
86
+ : bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp' : undefined
87
+ if (!mimeType) throw new Error('Unsupported image: use PNG, JPEG, or WebP (detected from file contents)')
88
+ images.push({ type: 'image', data: bytes.toString('base64'), mimeType })
89
+ } finally {
90
+ await file.close()
91
+ }
92
+ }
93
+ signal?.throwIfAborted()
94
+ return images
95
+ }
96
+
97
+ function decisionInput(context: any, model: any) {
98
+ if (context.videos?.length) throw new Error('Native video input is not supported by this adapter')
99
+ const images = context.images ?? []
100
+ if (!Array.isArray(images) || images.length > 4) throw new Error('At most 4 images are supported')
101
+ if (!images.length) return JSON.stringify(context.state)
102
+ if (!model.input?.includes('image')) throw new Error('Selected classifier does not support images')
103
+ let total = 0
104
+ for (const image of images) {
105
+ if (image?.type !== 'image' || !['image/png', 'image/jpeg', 'image/webp'].includes(image.mimeType) ||
106
+ typeof image.data !== 'string' || !image.data.length || image.data.length > 14 * 1024 * 1024 ||
107
+ !/^[A-Za-z0-9+/]+={0,2}$/.test(image.data)) throw new Error('Invalid or unsupported classifier image')
108
+ const decoded = Buffer.from(image.data, 'base64')
109
+ const size = decoded.length
110
+ if (!size || decoded.toString('base64').replace(/=+$/, '') !== image.data.replace(/=+$/, '')) throw new Error('Invalid classifier image base64')
111
+ total += size
112
+ if (size > 10 * 1024 * 1024 || total > 20 * 1024 * 1024) throw new Error('Classifier image size limit exceeded')
113
+ }
114
+ // OpenAI Decisions message format; do not put base64 inside state as ordinary JSON.
115
+ return [{ role: 'user', content: [
116
+ { type: 'input_text', text: JSON.stringify(context.state) },
117
+ ...images.map((image: any) => ({ type: 'input_image', image_url: `data:${image.mimeType};base64,${image.data}` })),
118
+ ] }]
119
+ }
120
+
121
+ function normalizeBaseUrl(baseUrl: string): string {
122
+ // Keep the versioned API base, but remove a legacy endpoint suffix.
123
+ return baseUrl.replace(/\/systemone\/?$/, '')
124
+ }
125
+
126
+ function toDecisionQuestions(questions: Record<string, any>) {
127
+ return Object.entries(questions).map(([name, question]) => {
128
+ if (question.type === 'choice') {
129
+ return {
130
+ type: 'choice',
131
+ name,
132
+ instructions: question.instructions,
133
+ choices: Object.entries(question.criteria || {}).map(([value, description]) =>
134
+ description ? { value, description } : { value }
135
+ ),
136
+ }
137
+ }
138
+
139
+ if (question.type === 'score') {
140
+ return {
141
+ type: 'score',
142
+ name,
143
+ instructions: question.instructions,
144
+ levels: (question.criteria || []).map((label: string) => ({ label })),
145
+ }
146
+ }
147
+
148
+ const meanings = [
149
+ question.criteria?.true ? `True means: ${question.criteria.true}` : '',
150
+ question.criteria?.false ? `False means: ${question.criteria.false}` : '',
151
+ ].filter(Boolean)
152
+ return {
153
+ type: 'predicate',
154
+ name,
155
+ instructions: [question.instructions, ...meanings].join('\n\n'),
156
+ }
157
+ })
158
+ }
159
+
160
+ function parseAnswers(response: any, questions: Record<string, any>) {
161
+ if (!Array.isArray(response?.answers)) throw new Error('Unexpected Decisions API response')
162
+ const byName = new Map<string, any>(response.answers.map((answer: any) => [answer.name, answer] as [string, any]))
163
+ if (byName.size !== response.answers.length || byName.size !== Object.keys(questions).length) {
164
+ throw new Error('Decisions API returned duplicate, missing, or unexpected answers')
165
+ }
166
+ const answers = Object.fromEntries(Object.entries(questions).map(([name, question]) => {
167
+ const answer = byName.get(name)
168
+ if (!answer || answer.type === 'refusal') throw new Error(`Decisions API did not answer ${name}`)
169
+ if (question.type === 'choice' && answer.type === 'choice') {
170
+ const probabilities = Object.fromEntries((answer.probabilities || []).map((p: any) => [p.value, p.probability]))
171
+ if (Object.keys(probabilities).length !== answer.probabilities?.length) throw new Error(`Invalid probabilities for ${name}`)
172
+ return [name, { type: 'choice', choice: answer.choice, probabilities, confidence: answer.confidence }]
173
+ }
174
+ if (question.type === 'score' && answer.type === 'score') {
175
+ return [name, { type: 'score', score: answer.score, confidence: answer.confidence }]
176
+ }
177
+ if (question.type === 'bool' && answer.type === 'predicate') {
178
+ return [name, { type: 'bool', probability: answer.probability }]
179
+ }
180
+ throw new Error(`Invalid Decisions API answer type for ${name}`)
181
+ }))
182
+ validateAnswers(answers, questions)
183
+ return answers
184
+ }
185
+
186
+ function parseUsage(usage: any, model: any) {
187
+ if (!usage || typeof usage !== 'object' || (usage.input_tokens === undefined && usage.output_tokens === undefined)) {
188
+ return undefined
189
+ }
190
+
191
+ const tokenCount = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
192
+ const totalInput = tokenCount(usage.input_tokens)
193
+ const output = tokenCount(usage.output_tokens)
194
+ const cacheRead = tokenCount(usage.input_tokens_details?.cached_tokens)
195
+ const cacheWrite = tokenCount(usage.input_tokens_details?.cache_write_tokens)
196
+ const input = Math.max(0, totalInput - cacheRead - cacheWrite)
197
+ const rates = model.cost || {}
198
+ const cost = {
199
+ input: input * (rates.input || 0) / 1_000_000,
200
+ output: output * (rates.output || 0) / 1_000_000,
201
+ cacheRead: cacheRead * (rates.cacheRead || 0) / 1_000_000,
202
+ cacheWrite: cacheWrite * (rates.cacheWrite || 0) / 1_000_000,
203
+ }
204
+
205
+ return {
206
+ input,
207
+ output,
208
+ cacheRead,
209
+ cacheWrite,
210
+ totalTokens: input + output + cacheRead + cacheWrite,
211
+ cost: { ...cost, total: cost.input + cost.output + cost.cacheRead + cost.cacheWrite },
212
+ }
213
+ }
214
+
215
+ async function classify(
216
+ providerId: string,
217
+ baseUrl: string,
218
+ configuredKey: string | undefined,
219
+ model: any,
220
+ context: any,
221
+ piDir: string,
222
+ options?: any,
223
+ ) {
224
+ const output: any = {
225
+ api: model.api,
226
+ provider: model.provider,
227
+ model: model.id,
228
+ answers: {},
229
+ stopReason: 'stop',
230
+ timestamp: Date.now(),
231
+ }
232
+
233
+ const signal = AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(30_000)])
234
+ let apiKey: string | undefined
235
+ try {
236
+ signal.throwIfAborted()
237
+ validateQuestions(context.questions)
238
+ if (!context.state || typeof context.state !== 'object' || Array.isArray(context.state)) throw new Error('state must be a JSON object')
239
+ if (Buffer.byteLength(JSON.stringify({ state: context.state, questions: context.questions })) > 256 * 1024) throw new Error('State and questions exceed 256 KiB')
240
+ apiKey = configuredKey !== undefined ? serviceApiKey(piDir, providerId, configuredKey)
241
+ : options?.apiKey || serviceApiKey(piDir, providerId, undefined)
242
+ if (!apiKey) throw new Error(`No API key configured for provider "${providerId}"`)
243
+
244
+ const response = await fetch(new URL('decisions', `${normalizeBaseUrl(baseUrl).replace(/\/+$/, '')}/`), {
245
+ method: 'POST',
246
+ headers: {
247
+ 'Content-Type': 'application/json',
248
+ Authorization: `Bearer ${apiKey}`,
249
+ },
250
+ body: JSON.stringify({
251
+ model: model.id,
252
+ input: decisionInput(context, model),
253
+ questions: toDecisionQuestions(context.questions),
254
+ }),
255
+ signal,
256
+ })
257
+
258
+ if (!response.ok) {
259
+ const body = (await response.text()).slice(0, 1024)
260
+ let message = body || `HTTP ${response.status}`
261
+ try {
262
+ const error = JSON.parse(body)
263
+ message = error.error?.message || error.message || message
264
+ } catch {}
265
+ throw new Error(`Decisions API returned HTTP ${response.status}: ${message}`)
266
+ }
267
+
268
+ const result = await response.json()
269
+ output.answers = parseAnswers(result, context.questions)
270
+ output.usage = parseUsage(result.usage, model)
271
+ } catch (error: any) {
272
+ output.stopReason = options?.signal?.aborted ? 'aborted' : 'error'
273
+ const message = signal.aborted && signal.reason?.name === 'TimeoutError'
274
+ ? 'Classifier request timed out after 30000ms' : error.message || String(error)
275
+ output.errorMessage = (apiKey ? message.replaceAll(apiKey, '<redacted>') : message).slice(0, 1100)
276
+ }
277
+
278
+ return output
279
+ }
280
+
281
+ export default function decisionsExtension(pi: ExtensionAPI) {
282
+ const piDir = process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || process.env.USERPROFILE || '', '.pi', 'agent')
283
+ const config = loadServiceConfig(piDir, 'classifier')
284
+ if (!config) return
285
+
286
+ for (const [providerId, providerConfig] of Object.entries(config.providers)) {
287
+ const { baseUrl, apiKey: configuredKey, models } = providerConfig
288
+ const classifierModels = models.map((model) => ({
289
+ type: 'classifier' as const,
290
+ id: model.id,
291
+ name: model.name ?? model.id,
292
+ api: 'openai-decisions' as const,
293
+ provider: providerId,
294
+ baseUrl: normalizeBaseUrl(baseUrl),
295
+ input: model.input!,
296
+ contextWindow: model.contextWindow!,
297
+ cost: {
298
+ input: model.cost?.input ?? 0,
299
+ output: model.cost?.output ?? 0,
300
+ cacheRead: model.cost?.cacheRead ?? 0,
301
+ cacheWrite: model.cost?.cacheWrite ?? 0,
302
+ },
303
+ }))
304
+
305
+ pi.registerProvider(providerId, {
306
+ baseUrl: normalizeBaseUrl(baseUrl),
307
+ apiKey: configuredApiKey(configuredKey) ?? '',
308
+ models: classifierModels,
309
+ classifiers: {
310
+ 'openai-decisions': {
311
+ classify: (model: any, context: any, options?: any) =>
312
+ classify(providerId, baseUrl, configuredKey, model, context, piDir, options),
313
+ },
314
+ },
315
+ })
316
+ }
317
+
318
+ const configuredModels = Object.entries(config.providers).flatMap(([provider, cfg]) => cfg.models.map(m => `${provider}/${m.id}`))
319
+ pi.registerTool({
320
+ name: 'classify',
321
+ label: 'Classify',
322
+ description: 'Ask configured classifiers narrow choice, bool, or score questions about JSON state and optional local PNG/JPEG/WebP images. Sends supplied data to the selected provider. 30-second request timeout; no retries or provider fallback. Video is not supported by this tool. Large output is saved to a temporary file.',
323
+ promptSnippet: 'Classify, route, or score text and images with a configured specialist model',
324
+ promptGuidelines: [
325
+ 'Use classify for repeated narrow semantic judgments, batch-item classification, or image judgments with explicit criteria; prefer code for exact rules and the main model for ordinary one-off reasoning.',
326
+ 'Ask independent questions about the same state together. Supply all evidence; use a no-match/unknown choice when needed. Image paths are local files whose pixels will be sent to the provider.',
327
+ 'Treat classifier confidence as a model signal, not calibrated correctness or permission to act. Do not use it as an authoritative security gate, send secrets, or silently retry failures with another provider.',
328
+ ],
329
+ parameters: Type.Object({
330
+ state: Type.Record(Type.String(), Type.Any(), { description: 'Shared JSON object containing the evidence to judge; use {} for image-only questions.' }),
331
+ questions: Type.Record(Type.String({ minLength: 1, maxLength: 128 }), questionSchema, { minProperties: 1, maxProperties: 32 }),
332
+ images: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { maxItems: 4, description: 'Local image paths, relative to cwd or absolute. Max 10 MiB each, 20 MiB total; PNG, JPEG, WebP.' })),
333
+ model: Type.Optional(Type.String({ description: `Configured provider/model-id; required when several are configured. Pick by strength:${modelOptions(config)}` })),
334
+ }, { additionalProperties: false }),
335
+ outputSchema: Type.Object({ provider: Type.String(), model: Type.String(), answers: Type.Record(Type.String(), Type.Any()), usage: Type.Optional(Type.Any()) }),
336
+ async execute(_id, params, signal, _onUpdate, ctx) {
337
+ signal?.throwIfAborted()
338
+ if ('videos' in params) throw new Error('Native video input is not supported by classify')
339
+ validateQuestions(params.questions)
340
+ const spec = params.model ?? (configuredModels.length === 1 ? configuredModels[0] : undefined)
341
+ if (!spec) throw new Error(`Specify model. Options:${modelOptions(config)}`)
342
+ if (!configuredModels.includes(spec)) throw new Error(`Model is not configured as a classifier in service-models.json: ${spec}`)
343
+ const slash = spec.indexOf('/')
344
+ const model = ctx.modelRegistry.findOfType('classifier', spec.slice(0, slash), spec.slice(slash + 1))
345
+ if (!model) throw new Error(`Configured classifier is not registered: ${spec}`)
346
+ if (params.images?.length && !model.input.includes('image')) throw new Error(`Model ${spec} does not support images`)
347
+ const images = await loadImages(params.images ?? [], ctx.cwd, signal)
348
+ const result = await ctx.modelRegistry.classify(model, { state: params.state, questions: params.questions, ...(images.length ? { images } : {}) }, { signal })
349
+ if (result.stopReason !== 'stop') throw new Error(result.errorMessage || `Classifier ${result.stopReason}`)
350
+ signal?.throwIfAborted()
351
+ validateAnswers(result.answers, params.questions)
352
+ const payload = { provider: result.provider, model: result.model, answers: result.answers, usage: result.usage }
353
+ const json = JSON.stringify(payload, null, 2)
354
+ const truncated = truncateHead(json, { maxBytes: 48 * 1024, maxLines: 1900 })
355
+ let text = truncated.content
356
+ if (truncated.truncated) {
357
+ const path = join(await mkdtemp(join(tmpdir(), 'pi-classify-')), 'result.json')
358
+ await writeFile(path, json, { encoding: 'utf8', mode: 0o600 })
359
+ text += `\n[Output truncated. Full JSON: ${path}]`
360
+ }
361
+ return { content: [{ type: 'text', text }], details: payload, structuredContent: payload as any, usage: result.usage }
362
+ },
363
+ })
364
+ }
@@ -0,0 +1,95 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export interface ServiceModel {
5
+ id: string
6
+ type: 'classifier' | 'tts'
7
+ api: 'openai-decisions' | 'openai-audio-speech'
8
+ name?: string
9
+ /** One-line strength hint shown in the tool's model options. */
10
+ description?: string
11
+ contextWindow?: number
12
+ input?: ('text' | 'image')[]
13
+ cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number }
14
+ voice?: string
15
+ language?: string
16
+ }
17
+ export interface ServiceProvider {
18
+ baseUrl: string
19
+ apiKey?: string
20
+ models: ServiceModel[]
21
+ }
22
+ const object = (value: any): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
23
+ const text = (value: any): value is string => typeof value === 'string' && !!value.trim()
24
+
25
+ /** Each extension uses every registry model of its `type`; a missing registry or no matching models disables the tool. */
26
+ export function loadServiceConfig(piDir: string, type: ServiceModel['type']): { providers: Record<string, ServiceProvider> } | undefined {
27
+ const path = join(piDir, 'extensions', 'service-models.json')
28
+ if (!existsSync(path)) return undefined
29
+ try {
30
+ let registry: any
31
+ try { registry = JSON.parse(readFileSync(path, 'utf8')) }
32
+ catch { throw new Error('Malformed JSON') }
33
+ if (!object(registry) || !object(registry.providers)) throw new Error('requires a providers object')
34
+ const providers: Record<string, ServiceProvider> = Object.create(null)
35
+ for (const [id, provider] of Object.entries(registry.providers)) {
36
+ if (!text(id) || id.includes('/') || !object(provider) || !text(provider.baseUrl) ||
37
+ (provider.apiKey !== undefined && typeof provider.apiKey !== 'string') || !Array.isArray(provider.models)) {
38
+ throw new Error(`Invalid provider: ${id}`)
39
+ }
40
+ let url: URL
41
+ try { url = new URL(provider.baseUrl) } catch { throw new Error(`Invalid baseUrl for provider: ${id}`) }
42
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error(`Invalid baseUrl for provider: ${id}`)
43
+ const ids = new Set<string>()
44
+ for (const model of provider.models) {
45
+ if (!object(model) || !text(model.id) || ids.has(model.id) ||
46
+ !['tts', 'classifier'].includes(model.type) || model.api !== (model.type === 'tts' ? 'openai-audio-speech' : 'openai-decisions') ||
47
+ (model.name !== undefined && !text(model.name)) ||
48
+ (model.description !== undefined && !text(model.description)) ||
49
+ ['voice', 'language'].some(key => model[key] !== undefined && (model.type !== 'tts' || !text(model[key]))) ||
50
+ (model.type === 'classifier' && (model.contextWindow === undefined || model.input === undefined)) ||
51
+ (model.contextWindow !== undefined && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) ||
52
+ (model.input !== undefined && (!Array.isArray(model.input) || !model.input.length ||
53
+ new Set(model.input).size !== model.input.length || model.input.some((input: any) => !['text', 'image'].includes(input)))) ||
54
+ (model.cost !== undefined && (!object(model.cost) || !['input', 'output'].every(key => typeof model.cost[key] === 'number') ||
55
+ Object.entries(model.cost).some(([key, value]) => !['input', 'output', 'cacheRead', 'cacheWrite'].includes(key) || typeof value !== 'number' || !Number.isFinite(value) || value < 0)))) {
56
+ throw new Error(`Invalid or duplicate model in provider: ${id}`)
57
+ }
58
+ ids.add(model.id)
59
+ if (model.type !== type) continue
60
+ providers[id] ??= { baseUrl: provider.baseUrl, ...(provider.apiKey !== undefined ? { apiKey: provider.apiKey } : {}), models: [] }
61
+ providers[id].models.push({ ...model } as ServiceModel)
62
+ }
63
+ }
64
+ return Object.keys(providers).length ? { providers } : undefined
65
+ } catch (error: any) {
66
+ // Never echo JSON parser messages, URLs or credential values from configuration.
67
+ throw new Error(`Failed to load service-models.json: ${error.message}`)
68
+ }
69
+ }
70
+
71
+ /** Lists each `provider/model-id` with its description, for a tool's model parameter. */
72
+ export function modelOptions(config: { providers: Record<string, ServiceProvider> }): string {
73
+ return Object.entries(config.providers).flatMap(([provider, cfg]) =>
74
+ cfg.models.map(model => `\n- ${provider}/${model.id}${model.description ? `: ${model.description}` : ''}`)).join('')
75
+ }
76
+
77
+ export function configuredApiKey(value: string | undefined): string | undefined {
78
+ if (value === undefined) return undefined
79
+ const envName = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value)?.[1]
80
+ ?? /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(value)?.[1]
81
+ return envName ? process.env[envName] : value
82
+ }
83
+
84
+ export function serviceApiKey(piDir: string, id: string, configured: string | undefined): string {
85
+ if (configured !== undefined) {
86
+ const key = configuredApiKey(configured)
87
+ if (key === undefined || (configured !== '' && !key)) throw new Error('No API key: Missing API key environment variable for configured service provider')
88
+ return key
89
+ }
90
+ let auth: any
91
+ try { auth = JSON.parse(readFileSync(join(piDir, 'auth.json'), 'utf8')) } catch {}
92
+ const key = auth?.[id]?.key ?? auth?.[id]?.access
93
+ if (typeof key !== 'string' || !key) throw new Error(`No API key configured for provider "${id}"; use auth.json or service-models.json apiKey ("" for keyless access)`)
94
+ return key
95
+ }
@@ -0,0 +1,71 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { loadExtension } from '../../../test/load-extension.mjs';
8
+
9
+ const helper = () => loadExtension(fileURLToPath(new URL('../src/lib/service-models.ts', import.meta.url)));
10
+ function fixture(t) {
11
+ const dir = mkdtempSync(join(tmpdir(), 'pi-service-test-'));
12
+ mkdirSync(join(dir, 'extensions'));
13
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
14
+ const save = (file, value) => writeFileSync(join(dir, 'extensions', file), JSON.stringify(value));
15
+ const registry = { providers: { local: { baseUrl: 'http://localhost:8000/v1', apiKey: '$PI_TEST_KEY', models: [
16
+ { id: 'judge', type: 'classifier', api: 'openai-decisions', input: ['text', 'image'], contextWindow: 8192 },
17
+ { id: 'voice', type: 'tts', api: 'openai-audio-speech' },
18
+ ] } } };
19
+ save('service-models.json', registry);
20
+ return { dir, save, registry };
21
+ }
22
+
23
+ test('missing registry disables; each type sees only its own models', t => {
24
+ const { dir, save, registry } = fixture(t);
25
+ const { loadServiceConfig, modelOptions } = helper();
26
+ const cfg = loadServiceConfig(dir, 'classifier');
27
+ assert.deepEqual([...cfg.providers.local.models.map(m => m.id)], ['judge']);
28
+ assert.deepEqual([...loadServiceConfig(dir, 'tts').providers.local.models.map(m => m.id)], ['voice']);
29
+ registry.providers.local.models[0].description = 'Accurate on close calls.';
30
+ save('service-models.json', registry);
31
+ assert.equal(modelOptions(loadServiceConfig(dir, 'classifier')), '\n- local/judge: Accurate on close calls.');
32
+ save('service-models.json', { providers: { local: { ...registry.providers.local, models: [registry.providers.local.models[1]] } } });
33
+ assert.equal(loadServiceConfig(dir, 'classifier'), undefined);
34
+ rmSync(join(dir, 'extensions', 'service-models.json'));
35
+ assert.equal(loadServiceConfig(dir, 'classifier'), undefined);
36
+ });
37
+
38
+ test('invalid URLs and model schemas fail without leaking values', t => {
39
+ const { dir, save, registry } = fixture(t);
40
+ const { loadServiceConfig } = helper();
41
+ registry.providers.local.baseUrl = 'https://user:secret@example.invalid/v1';
42
+ save('service-models.json', registry);
43
+ assert.throws(() => loadServiceConfig(dir, 'classifier'), error => /Invalid baseUrl/.test(error.message) && !error.message.includes('secret'));
44
+ registry.providers.local.baseUrl = 'http://localhost:8000/v1';
45
+ registry.providers.local.models[0].contextWindow = 0;
46
+ save('service-models.json', registry);
47
+ assert.throws(() => loadServiceConfig(dir, 'classifier'), /Invalid or duplicate model/);
48
+ registry.providers.local.models[0].contextWindow = 8192;
49
+ registry.providers.local.models[0].description = ' ';
50
+ save('service-models.json', registry);
51
+ assert.throws(() => loadServiceConfig(dir, 'classifier'), /Invalid or duplicate model/);
52
+ writeFileSync(join(dir, 'extensions', 'service-models.json'), '{"secret": invalid');
53
+ assert.throws(() => loadServiceConfig(dir, 'classifier'), error => /Malformed JSON/.test(error.message) && !error.message.includes('secret'));
54
+ });
55
+
56
+ test('environment resolution, missing environment, explicit keyless and auth fallback', t => {
57
+ const { dir } = fixture(t);
58
+ const { configuredApiKey, serviceApiKey } = helper();
59
+ const old = process.env.PI_TEST_KEY;
60
+ t.after(() => { if (old === undefined) delete process.env.PI_TEST_KEY; else process.env.PI_TEST_KEY = old; });
61
+ process.env.PI_TEST_KEY = 'test-value';
62
+ assert.equal(configuredApiKey('$PI_TEST_KEY'), 'test-value');
63
+ assert.equal(configuredApiKey('${PI_TEST_KEY}'), 'test-value');
64
+ assert.equal(configuredApiKey('literal'), 'literal');
65
+ assert.equal(configuredApiKey(undefined), undefined);
66
+ assert.equal(serviceApiKey(dir, 'local', ''), '');
67
+ delete process.env.PI_TEST_KEY;
68
+ assert.throws(() => serviceApiKey(dir, 'local', '$PI_TEST_KEY'), /Missing API key environment/);
69
+ writeFileSync(join(dir, 'auth.json'), JSON.stringify({ local: { key: 'fixture-key' } }));
70
+ assert.equal(serviceApiKey(dir, 'local', undefined), 'fixture-key');
71
+ });