@fastagent-sh/fastagent 0.12.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.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/dist/agent.d.ts +72 -0
  4. package/dist/agent.js +14 -0
  5. package/dist/channels/body.d.ts +9 -0
  6. package/dist/channels/body.js +29 -0
  7. package/dist/channels/github/github.d.ts +31 -0
  8. package/dist/channels/github/github.js +80 -0
  9. package/dist/channels/github/scaffold/channel.ts +25 -0
  10. package/dist/channels/http.d.ts +13 -0
  11. package/dist/channels/http.js +161 -0
  12. package/dist/channels/respond.d.ts +5 -0
  13. package/dist/channels/respond.js +3 -0
  14. package/dist/channels/telegram/context-buffer.d.ts +59 -0
  15. package/dist/channels/telegram/context-buffer.js +139 -0
  16. package/dist/channels/telegram/invoke-turn.d.ts +53 -0
  17. package/dist/channels/telegram/invoke-turn.js +127 -0
  18. package/dist/channels/telegram/parse.d.ts +160 -0
  19. package/dist/channels/telegram/parse.js +153 -0
  20. package/dist/channels/telegram/preview.d.ts +22 -0
  21. package/dist/channels/telegram/preview.js +266 -0
  22. package/dist/channels/telegram/register-webhook.d.ts +13 -0
  23. package/dist/channels/telegram/register-webhook.js +73 -0
  24. package/dist/channels/telegram/scaffold/channel.ts +25 -0
  25. package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
  26. package/dist/channels/telegram/state.d.ts +9 -0
  27. package/dist/channels/telegram/state.js +58 -0
  28. package/dist/channels/telegram/telegram-api.d.ts +77 -0
  29. package/dist/channels/telegram/telegram-api.js +345 -0
  30. package/dist/channels/telegram/telegram.d.ts +35 -0
  31. package/dist/channels/telegram/telegram.js +352 -0
  32. package/dist/channels/telegram/turn-queue.d.ts +21 -0
  33. package/dist/channels/telegram/turn-queue.js +47 -0
  34. package/dist/channels/telegram/turn-store.d.ts +38 -0
  35. package/dist/channels/telegram/turn-store.js +150 -0
  36. package/dist/cli-auth.d.ts +24 -0
  37. package/dist/cli-auth.js +30 -0
  38. package/dist/cli-models.d.ts +11 -0
  39. package/dist/cli-models.js +20 -0
  40. package/dist/cli.d.ts +2 -0
  41. package/dist/cli.js +1288 -0
  42. package/dist/collect.d.ts +20 -0
  43. package/dist/collect.js +27 -0
  44. package/dist/core.d.ts +10 -0
  45. package/dist/core.js +8 -0
  46. package/dist/deploy/container.d.ts +56 -0
  47. package/dist/deploy/container.js +169 -0
  48. package/dist/deploy/fly/plan.d.ts +65 -0
  49. package/dist/deploy/fly/plan.js +142 -0
  50. package/dist/deploy/fly/run.d.ts +51 -0
  51. package/dist/deploy/fly/run.js +89 -0
  52. package/dist/deploy/preflight.d.ts +59 -0
  53. package/dist/deploy/preflight.js +191 -0
  54. package/dist/deploy/railway/plan.d.ts +48 -0
  55. package/dist/deploy/railway/plan.js +91 -0
  56. package/dist/deploy/railway/run.d.ts +81 -0
  57. package/dist/deploy/railway/run.js +176 -0
  58. package/dist/deploy/runner.d.ts +18 -0
  59. package/dist/deploy/runner.js +25 -0
  60. package/dist/deploy/secrets.d.ts +49 -0
  61. package/dist/deploy/secrets.js +88 -0
  62. package/dist/dev-supervisor.d.ts +14 -0
  63. package/dist/dev-supervisor.js +158 -0
  64. package/dist/engines/pi/auth.d.ts +17 -0
  65. package/dist/engines/pi/auth.js +118 -0
  66. package/dist/engines/pi/channel.d.ts +31 -0
  67. package/dist/engines/pi/channel.js +113 -0
  68. package/dist/engines/pi/chat.d.ts +19 -0
  69. package/dist/engines/pi/chat.js +200 -0
  70. package/dist/engines/pi/config.d.ts +110 -0
  71. package/dist/engines/pi/config.js +285 -0
  72. package/dist/engines/pi/create.d.ts +127 -0
  73. package/dist/engines/pi/create.js +222 -0
  74. package/dist/engines/pi/definition.d.ts +72 -0
  75. package/dist/engines/pi/definition.js +129 -0
  76. package/dist/engines/pi/harness.d.ts +50 -0
  77. package/dist/engines/pi/harness.js +41 -0
  78. package/dist/engines/pi/invoke.d.ts +28 -0
  79. package/dist/engines/pi/invoke.js +298 -0
  80. package/dist/engines/pi/login.d.ts +51 -0
  81. package/dist/engines/pi/login.js +125 -0
  82. package/dist/engines/pi/models.d.ts +41 -0
  83. package/dist/engines/pi/models.js +76 -0
  84. package/dist/engines/pi/report.d.ts +13 -0
  85. package/dist/engines/pi/report.js +21 -0
  86. package/dist/engines/pi/sessions.d.ts +15 -0
  87. package/dist/engines/pi/sessions.js +130 -0
  88. package/dist/engines/pi/tool-context.d.ts +17 -0
  89. package/dist/engines/pi/tool-context.js +13 -0
  90. package/dist/engines/pi/tool.d.ts +45 -0
  91. package/dist/engines/pi/tool.js +85 -0
  92. package/dist/engines/pi/wake-tool.d.ts +20 -0
  93. package/dist/engines/pi/wake-tool.js +110 -0
  94. package/dist/engines/pi/workspace.d.ts +53 -0
  95. package/dist/engines/pi/workspace.js +70 -0
  96. package/dist/env.d.ts +24 -0
  97. package/dist/env.js +57 -0
  98. package/dist/github.d.ts +3 -0
  99. package/dist/github.js +2 -0
  100. package/dist/host/node.d.ts +41 -0
  101. package/dist/host/node.js +50 -0
  102. package/dist/index.d.ts +2 -0
  103. package/dist/index.js +5 -0
  104. package/dist/invoke-stream.d.ts +10 -0
  105. package/dist/invoke-stream.js +26 -0
  106. package/dist/loader.d.ts +38 -0
  107. package/dist/loader.js +69 -0
  108. package/dist/log.d.ts +26 -0
  109. package/dist/log.js +51 -0
  110. package/dist/observe.d.ts +12 -0
  111. package/dist/observe.js +44 -0
  112. package/dist/pi.d.ts +16 -0
  113. package/dist/pi.js +12 -0
  114. package/dist/proxy.d.ts +9 -0
  115. package/dist/proxy.js +23 -0
  116. package/dist/runtime.d.ts +26 -0
  117. package/dist/runtime.js +29 -0
  118. package/dist/scaffold/add-channel.d.ts +46 -0
  119. package/dist/scaffold/add-channel.js +227 -0
  120. package/dist/scaffold/init.d.ts +54 -0
  121. package/dist/scaffold/init.js +249 -0
  122. package/dist/scaffold/templates/env.example +24 -0
  123. package/dist/scaffold/templates/fastagent.config.mjs +19 -0
  124. package/dist/scaffold/templates/gitignore +8 -0
  125. package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
  126. package/dist/scaffold/templates/gitignore.kit +2 -0
  127. package/dist/scaffold/templates/persona.md +10 -0
  128. package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
  129. package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
  130. package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
  131. package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
  132. package/dist/scaffold/templates.d.ts +17 -0
  133. package/dist/scaffold/templates.js +56 -0
  134. package/dist/scaffold/vendor-skill.d.ts +24 -0
  135. package/dist/scaffold/vendor-skill.js +157 -0
  136. package/dist/schedule/audit.d.ts +18 -0
  137. package/dist/schedule/audit.js +57 -0
  138. package/dist/schedule/cron.d.ts +7 -0
  139. package/dist/schedule/cron.js +33 -0
  140. package/dist/schedule/discover.d.ts +17 -0
  141. package/dist/schedule/discover.js +64 -0
  142. package/dist/schedule/schedule.d.ts +35 -0
  143. package/dist/schedule/schedule.js +23 -0
  144. package/dist/schedule/scheduler.d.ts +36 -0
  145. package/dist/schedule/scheduler.js +253 -0
  146. package/dist/schedule/state.d.ts +14 -0
  147. package/dist/schedule/state.js +56 -0
  148. package/dist/schedule/wakeups.d.ts +82 -0
  149. package/dist/schedule/wakeups.js +196 -0
  150. package/dist/telegram.d.ts +2 -0
  151. package/dist/telegram.js +2 -0
  152. package/dist/tunnel.d.ts +31 -0
  153. package/dist/tunnel.js +139 -0
  154. package/dist/version.d.ts +5 -0
  155. package/dist/version.js +13 -0
  156. package/dist/workspace.d.ts +9 -0
  157. package/dist/workspace.js +45 -0
  158. package/package.json +97 -0
package/dist/log.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Leveled logging. Runtime logs (lifecycle, warnings, errors, the debug turn trace) flow through ONE
3
+ * process-level logger, gated by a single level. It is a module singleton — not an injected dependency —
4
+ * because most runtime logs originate inside author-constructed channels (`channels/*.ts` call the
5
+ * channel factory themselves) and deep engine code, which the CLI cannot thread a logger into. The CLI
6
+ * sets the level by posture (dev → debug, start → info); `FASTAGENT_LOG_LEVEL` overrides it.
7
+ *
8
+ * This is NOT the CLI's user-facing output (help text, command results): that is the program talking to
9
+ * its operator and stays on plain `console`. Everything here is operational logging to stderr.
10
+ */
11
+ const ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
12
+ const isLevel = (s) => s in ORDER;
13
+ const format = (level, msg) => `${level.toUpperCase().padEnd(5)} ${msg}`;
14
+ /** A standalone logger over an explicit sink — used in tests to assert level gating without the singleton. */
15
+ export function createLogger(opts) {
16
+ const sink = opts.sink ?? ((line) => console.error(line));
17
+ const make = (level) => (msg) => {
18
+ if (ORDER[level] >= ORDER[opts.level])
19
+ sink(format(level, msg));
20
+ };
21
+ return { debug: make("debug"), info: make("info"), warn: make("warn"), error: make("error") };
22
+ }
23
+ /**
24
+ * `FASTAGENT_LOG_LEVEL` parsed to three states: a valid value locks the level (overrides posture); a
25
+ * present-but-invalid value warns and is treated as absent, so a typo (meant to make logs louder) can
26
+ * never silently pin the level to info nor kill the posture default; absent returns undefined. The
27
+ * warning is raw — the singleton below is not built yet — but reuses `format` for the same shape.
28
+ */
29
+ function parseEnvOverride() {
30
+ const raw = process.env.FASTAGENT_LOG_LEVEL;
31
+ if (raw === undefined)
32
+ return undefined;
33
+ const value = raw.toLowerCase();
34
+ if (isLevel(value))
35
+ return value;
36
+ console.error(format("warn", `[fastagent] unknown FASTAGENT_LOG_LEVEL "${raw}"; using the posture default`));
37
+ return undefined;
38
+ }
39
+ const override = parseEnvOverride();
40
+ let currentLevel = override ?? "info";
41
+ /** Set the posture default. A valid `FASTAGENT_LOG_LEVEL` override, if present, wins and is not changed. */
42
+ export function setLogLevel(level) {
43
+ if (override === undefined)
44
+ currentLevel = level;
45
+ }
46
+ const emit = (level) => (msg) => {
47
+ if (ORDER[level] >= ORDER[currentLevel])
48
+ console.error(format(level, msg));
49
+ };
50
+ /** The process logger. Runtime code imports this and calls `log.info(...)` etc. */
51
+ export const log = { debug: emit("debug"), info: emit("info"), warn: emit("warn"), error: emit("error") };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The turn trace: wrap an Agent to tee each turn's loop — prompt, reasoning, tool calls (name + args)
3
+ * with results, reply, terminal — to the log at DEBUG level. Pass-through: events are forwarded
4
+ * untouched; this only adds a readable line per step. Engine-neutral (the contract only).
5
+ *
6
+ * It is wired in BOTH `dev` and `start`; the level decides visibility. dev runs at debug, so the trace
7
+ * (including end-user content — prompt, tool args/results, reply) shows; start runs at info, so the
8
+ * trace is gated out entirely, keeping that content and its volume out of production logs. That gating
9
+ * is why the trace is debug-level rather than a switch soldered to the command path.
10
+ */
11
+ import type { Agent } from "./agent.ts";
12
+ export declare function logAgentLoop(agent: Agent, sink?: (line: string) => void): Agent;
@@ -0,0 +1,44 @@
1
+ import { log } from "./log.js";
2
+ const PREVIEW = 200;
3
+ /** One line, whitespace-collapsed, truncated — a log stays scannable. */
4
+ function oneLine(s) {
5
+ const t = s.replace(/\s+/g, " ").trim();
6
+ return t.length > PREVIEW ? `${t.slice(0, PREVIEW - 1)}…` : t;
7
+ }
8
+ const preview = (v) => oneLine(typeof v === "string" ? v : JSON.stringify(v));
9
+ export function logAgentLoop(agent, sink = (line) => log.debug(line)) {
10
+ return {
11
+ async *invoke(scope, prompt) {
12
+ const s = scope.session;
13
+ sink(`[agent] ▶ turn session=${s} ← ${oneLine(prompt.text)}`);
14
+ const toolName = new Map(); // tool_ended carries no name — remember it from tool_started
15
+ let thinking = "";
16
+ let reply = "";
17
+ for await (const e of agent.invoke(scope, prompt)) {
18
+ if (e.type === "text") {
19
+ reply += e.delta;
20
+ }
21
+ else if (e.type === "thinking") {
22
+ thinking += e.delta;
23
+ }
24
+ else if (e.type === "tool_started") {
25
+ toolName.set(e.id, e.name);
26
+ sink(`[agent] tool → ${e.name}(${preview(e.args)})`);
27
+ }
28
+ else if (e.type === "tool_ended") {
29
+ sink(`[agent] tool ${e.isError ? "✗" : "✓"} ${toolName.get(e.id) ?? e.id} → ${preview(e.content)}`);
30
+ }
31
+ else if (e.type === "completed") {
32
+ if (thinking.trim() !== "")
33
+ sink(`[agent] thinking: ${oneLine(thinking)}`);
34
+ sink(`[agent] reply: ${oneLine(reply) || "(empty)"}`);
35
+ sink(`[agent] ■ completed session=${s}`);
36
+ }
37
+ else if (e.type === "failed") {
38
+ sink(`[agent] ■ failed session=${s}: ${e.details} (retryable=${e.retryable})`);
39
+ }
40
+ yield e;
41
+ }
42
+ },
43
+ };
44
+ }
package/dist/pi.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { createPiAgent, createPiAgentFromDefinition, type CreatePiAgentFromDefinitionOptions, type CreatePiAgentOptions, } from "./engines/pi/create.ts";
2
+ export { defineTool, loadTools, type DefineToolOptions, type ToolCollision, type ToolContext, } from "./engines/pi/tool.ts";
3
+ export { z } from "zod";
4
+ export type { AgentTool, ExecutionEnv, Session, Skill, SkillDiagnostic } from "@earendil-works/pi-agent-core";
5
+ export { loadChannels, type ChannelCollision } from "./engines/pi/channel.ts";
6
+ export { createPiAgentFromWorkspace, type CreatePiAgentFromWorkspaceOptions, } from "./engines/pi/workspace.ts";
7
+ export type { LoadedDefinition, SkillCollision } from "./engines/pi/definition.ts";
8
+ export { defineConfig, listModels, resolveModel, type FastagentConfig } from "./engines/pi/config.ts";
9
+ export { inProcessLease, type Lease, type Release } from "./engines/pi/invoke.ts";
10
+ export type { AnyModel } from "./engines/pi/harness.ts";
11
+ export { inMemorySessionStore, jsonlSessionStore, type PiSessionStore } from "./engines/pi/sessions.ts";
12
+ export { GLOBAL_AUTH_PATH, fastagentCredentialStore, type FastagentAuthOptions } from "./engines/pi/auth.ts";
13
+ export { createPiModels, probeAuthSource, type CreatePiModelsOptions } from "./engines/pi/models.ts";
14
+ export type { Models } from "@earendil-works/pi-ai";
15
+ export { createProvider, type Provider, type ProviderAuth } from "@earendil-works/pi-ai";
16
+ export type { Model } from "@earendil-works/pi-ai";
package/dist/pi.js ADDED
@@ -0,0 +1,12 @@
1
+ // The pi reference implementation: assembly, workspace discovery, tools, config, models, auth, and state ports.
2
+ export { createPiAgent, createPiAgentFromDefinition, } from "./engines/pi/create.js";
3
+ export { defineTool, loadTools, } from "./engines/pi/tool.js";
4
+ export { z } from "zod";
5
+ export { loadChannels } from "./engines/pi/channel.js";
6
+ export { createPiAgentFromWorkspace, } from "./engines/pi/workspace.js";
7
+ export { defineConfig, listModels, resolveModel } from "./engines/pi/config.js";
8
+ export { inProcessLease } from "./engines/pi/invoke.js";
9
+ export { inMemorySessionStore, jsonlSessionStore } from "./engines/pi/sessions.js";
10
+ export { GLOBAL_AUTH_PATH, fastagentCredentialStore } from "./engines/pi/auth.js";
11
+ export { createPiModels, probeAuthSource } from "./engines/pi/models.js";
12
+ export { createProvider } from "@earendil-works/pi-ai";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Route fetch through HTTPS_PROXY and keep fetch + dispatcher on the SAME undici implementation. Any
3
+ * process that hits a provider or a channel API needs this UNDER NODE: Node's fetch does not honor
4
+ * HTTPS_PROXY by itself (so login's OAuth token exchange, model calls, and a webhook setWebhook would go
5
+ * direct, bypassing a region proxy), and Node 26's bundled fetch skips gzip decompression without
6
+ * install() (empty stopReason:"stop"). A process-global side effect — call it once at a command/entry
7
+ * start. No-op under Bun, whose native fetch already does both (and lacks the undici entry points).
8
+ */
9
+ export declare function installProxyFetch(): void;
package/dist/proxy.js ADDED
@@ -0,0 +1,23 @@
1
+ // Namespace import (not `import { install }`): Bun's `undici` is a native shim that omits `install`, and
2
+ // a STATIC NAMED import of a missing export fails at link time — crashing `fastagent start` under Bun
3
+ // before any code runs. A namespace import binds whatever exports exist; the Bun guard below then never
4
+ // touches the Node-only members.
5
+ import * as undici from "undici";
6
+ /** The Bun runtime (`process.versions.bun` is set only there). Bun's native fetch already honors the
7
+ * proxy env vars and decompresses gzip, and its `undici` shim has no `install()` — so the Node-only
8
+ * undici wiring below is both unnecessary and unavailable there. */
9
+ const isBun = typeof process.versions.bun === "string";
10
+ /**
11
+ * Route fetch through HTTPS_PROXY and keep fetch + dispatcher on the SAME undici implementation. Any
12
+ * process that hits a provider or a channel API needs this UNDER NODE: Node's fetch does not honor
13
+ * HTTPS_PROXY by itself (so login's OAuth token exchange, model calls, and a webhook setWebhook would go
14
+ * direct, bypassing a region proxy), and Node 26's bundled fetch skips gzip decompression without
15
+ * install() (empty stopReason:"stop"). A process-global side effect — call it once at a command/entry
16
+ * start. No-op under Bun, whose native fetch already does both (and lacks the undici entry points).
17
+ */
18
+ export function installProxyFetch() {
19
+ if (isBun)
20
+ return;
21
+ undici.setGlobalDispatcher(new undici.EnvHttpProxyAgent());
22
+ undici.install();
23
+ }
@@ -0,0 +1,26 @@
1
+ export interface WorkspaceRuntime {
2
+ /** The JS runtime the workspace targets — drives the generated Dockerfile base + install/run commands
3
+ * and the package-manager hints in `init`/`add`. */
4
+ runtime: "node" | "bun";
5
+ /** For `runtime: "bun"`, the version from package.json's `packageManager: "bun@x"` (undefined if a bun
6
+ * lockfile made it bun but no version is pinned). */
7
+ bunVersion?: string;
8
+ /** Whether the runtime's lockfile is present (package-lock.json for node, bun.lock/bun.lockb for bun). */
9
+ hasLockfile: boolean;
10
+ }
11
+ /**
12
+ * Detect which JS runtime a workspace targets: `bun` when package.json's `packageManager` is `bun@…` OR a
13
+ * bun lockfile (bun.lock/bun.lockb) is present, else `node`. `pkg` is the already-parsed package.json (or
14
+ * `{}` when there is none / it is malformed). One source for the deploy Dockerfile and the CLI hints, so
15
+ * they agree on what the workspace is.
16
+ */
17
+ export declare function detectRuntime(dir: string, pkg: {
18
+ packageManager?: unknown;
19
+ }): WorkspaceRuntime;
20
+ /** Parse `<dir>/package.json`, or `{}` when absent/malformed (a real build surfaces the actual error).
21
+ * The already-parsed input to {@link detectRuntime} and the deploy dep/lockfile checks. */
22
+ export declare function readPackageJson(dir: string): Promise<{
23
+ packageManager?: unknown;
24
+ dependencies?: Record<string, unknown>;
25
+ devDependencies?: Record<string, unknown>;
26
+ }>;
@@ -0,0 +1,29 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ /**
5
+ * Detect which JS runtime a workspace targets: `bun` when package.json's `packageManager` is `bun@…` OR a
6
+ * bun lockfile (bun.lock/bun.lockb) is present, else `node`. `pkg` is the already-parsed package.json (or
7
+ * `{}` when there is none / it is malformed). One source for the deploy Dockerfile and the CLI hints, so
8
+ * they agree on what the workspace is.
9
+ */
10
+ export function detectRuntime(dir, pkg) {
11
+ const pm = typeof pkg.packageManager === "string" ? pkg.packageManager : "";
12
+ const bunLock = existsSync(join(dir, "bun.lock")) || existsSync(join(dir, "bun.lockb"));
13
+ if (pm.startsWith("bun@") || bunLock) {
14
+ // corepack pins as `bun@1.3.13+sha256.<hash>`; the version (a Docker tag for oven/bun) is the part
15
+ // BEFORE `+` — keeping the hash would build an invalid `FROM oven/bun:1.3.13+sha256…` tag.
16
+ return { runtime: "bun", bunVersion: pm.match(/^bun@([^+]+)/)?.[1], hasLockfile: bunLock };
17
+ }
18
+ return { runtime: "node", hasLockfile: existsSync(join(dir, "package-lock.json")) };
19
+ }
20
+ /** Parse `<dir>/package.json`, or `{}` when absent/malformed (a real build surfaces the actual error).
21
+ * The already-parsed input to {@link detectRuntime} and the deploy dep/lockfile checks. */
22
+ export async function readPackageJson(dir) {
23
+ try {
24
+ return JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
@@ -0,0 +1,46 @@
1
+ export type ChannelKind = "github" | "telegram";
2
+ /** An env var a scaffolded channel reads. `generate` = a random-string secret the CLI can pre-fill. */
3
+ export interface ChannelEnv {
4
+ name: string;
5
+ hint: string;
6
+ generate?: boolean;
7
+ }
8
+ /** The channel kinds `fastagent add <kind>` can scaffold. */
9
+ export declare const CHANNEL_KINDS: ChannelKind[];
10
+ /** The env vars + next-step lines a scaffolded channel needs (for the CLI to print). */
11
+ export declare function channelSetup(kind: ChannelKind): {
12
+ env: ChannelEnv[];
13
+ steps: string[];
14
+ };
15
+ /**
16
+ * Append a channel's env vars (commented placeholders + hints) to `.env.example`, so a developer who
17
+ * copies it to `.env` finds the vars already there. No-op when there is no `.env.example` or the block
18
+ * is already present. Placeholders only — no real secret lands in the committable template.
19
+ */
20
+ export declare function appendChannelEnv(dir: string, kind: ChannelKind): Promise<boolean>;
21
+ export interface DotEnvWriteResult {
22
+ /** Generated secret vars written as active `KEY=value` lines. */
23
+ written: string[];
24
+ /** Vars already present with a non-empty active value; left untouched and omitted from next steps. */
25
+ alreadySet: string[];
26
+ }
27
+ /**
28
+ * Append generated channel secrets to the run-root `.env` (never `.env.example`) after the CLI has
29
+ * verified that `.env` is gitignored. Existing non-empty values are kept. Manual values (e.g.
30
+ * TELEGRAM_BOT_TOKEN from BotFather) are added only as commented placeholders, so the file is ready to
31
+ * edit while no fake secret is committed to the user's mental model.
32
+ */
33
+ export declare function appendChannelDotEnv(dir: string, kind: ChannelKind, generated: Record<string, string>): Promise<DotEnvWriteResult>;
34
+ /** Whether a channel file already exists — checked before any mutation, so a no-clobber re-add is side-effect-free. */
35
+ export declare function channelExists(dir: string, kind: ChannelKind): Promise<boolean>;
36
+ /**
37
+ * Scaffold `channels/<kind>.ts` into {@link dir}. Never clobbers an existing file (the glue is
38
+ * authored content). The wx write is the TOCTOU safety net behind {@link channelExists}.
39
+ */
40
+ export declare function scaffoldChannel(dir: string, kind: ChannelKind): Promise<string>;
41
+ /**
42
+ * Verify the workspace is ready to host a channel: an ESM package.json that declares
43
+ * `@fastagent-sh/fastagent` (the channel file imports it). `add` checks and guides, never bootstraps — that
44
+ * is `init`'s job.
45
+ */
46
+ export declare function assertChannelReady(dir: string): Promise<void>;
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `fastagent add <channel>`: drop a `channels/<kind>.ts` adapter-glue file (+ any companion tool, +
3
+ * `.env.example` vars) into an existing workspace. `add` checks and guides; it never bootstraps a
4
+ * workspace (that is `init`'s job). Each channel's template files live in its own bundle at
5
+ * src/channels/<kind>/scaffold/, read here at scaffold time.
6
+ */
7
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { detectRuntime } from "../runtime.js";
10
+ import { assertInsideWorkspace } from "../workspace.js";
11
+ import { channelBundleFiles, channelTemplate } from "./templates.js";
12
+ import { exists } from "./init.js";
13
+ import { parseEnvContent } from "../env.js";
14
+ const CHANNEL_SCAFFOLDS = {
15
+ github: {
16
+ env: [
17
+ {
18
+ name: "GITHUB_WEBHOOK_SECRET",
19
+ hint: "any random string; set the same value in the GitHub webhook",
20
+ generate: true,
21
+ },
22
+ ],
23
+ // `{channel}` / `{tools}` are path placeholders the CLI resolves to the real workspace-relative
24
+ // location (agentDir-aware) — the CLI holds no channel-private filenames.
25
+ steps: [
26
+ "edit {channel} — map events to intents in on()",
27
+ "add the webhook in your repo (Settings → Webhooks): Payload URL = <public-url>/webhook, content type application/json",
28
+ ],
29
+ },
30
+ telegram: {
31
+ env: [
32
+ { name: "TELEGRAM_BOT_TOKEN", hint: "from @BotFather → /newbot" },
33
+ { name: "TELEGRAM_SECRET_TOKEN", hint: "any random string; verifies inbound updates", generate: true },
34
+ ],
35
+ steps: [
36
+ "edit {channel} — customise routing with route() (optional; the defaults already work)",
37
+ "the agent can send messages or files back by calling the scaffolded {tools}/telegram-send.ts tool",
38
+ ],
39
+ },
40
+ };
41
+ /** The channel kinds `fastagent add <kind>` can scaffold. */
42
+ export const CHANNEL_KINDS = Object.keys(CHANNEL_SCAFFOLDS);
43
+ /** The env vars + next-step lines a scaffolded channel needs (for the CLI to print). */
44
+ export function channelSetup(kind) {
45
+ const { env, steps } = CHANNEL_SCAFFOLDS[kind];
46
+ return { env, steps };
47
+ }
48
+ /**
49
+ * Append a channel's env vars (commented placeholders + hints) to `.env.example`, so a developer who
50
+ * copies it to `.env` finds the vars already there. No-op when there is no `.env.example` or the block
51
+ * is already present. Placeholders only — no real secret lands in the committable template.
52
+ */
53
+ export async function appendChannelEnv(dir, kind) {
54
+ const file = join(dir, ".env.example");
55
+ let current;
56
+ try {
57
+ current = await readFile(file, "utf8");
58
+ }
59
+ catch (e) {
60
+ if (e.code === "ENOENT")
61
+ return false;
62
+ throw e;
63
+ }
64
+ const marker = `# --- ${kind} channel ---`;
65
+ if (current.includes(marker))
66
+ return false;
67
+ // Hint on its OWN line above the placeholder (like the base env.example template) — never inline
68
+ // after `=`: loadEnvFile does not strip trailing comments, so an uncommented `KEY= # hint` (or a
69
+ // value pasted before the `#`) would carry the hint text into the parsed value.
70
+ const block = `\n${marker}\n${CHANNEL_SCAFFOLDS[kind].env.map((e) => `# ${e.hint}\n# ${e.name}=`).join("\n")}\n`;
71
+ await appendFile(file, block);
72
+ return true;
73
+ }
74
+ /** Whether `.env` content carries a non-empty ACTIVE value for `name` — decided by THE .env parser
75
+ * ({@link parseEnvContent}), not a re-implementation, so this check can never disagree with what
76
+ * `loadEnvFile` will actually read (a missed match here would append a second assignment that
77
+ * last-wins over the user's working secret). */
78
+ function hasActiveEnvValue(content, name) {
79
+ return (parseEnvContent(content).get(name)?.trim() ?? "") !== "";
80
+ }
81
+ function mentionsEnvName(content, name) {
82
+ return content.split("\n").some((line) => new RegExp(`^\\s*#?\\s*${name}\\s*=`).test(line));
83
+ }
84
+ /**
85
+ * Append generated channel secrets to the run-root `.env` (never `.env.example`) after the CLI has
86
+ * verified that `.env` is gitignored. Existing non-empty values are kept. Manual values (e.g.
87
+ * TELEGRAM_BOT_TOKEN from BotFather) are added only as commented placeholders, so the file is ready to
88
+ * edit while no fake secret is committed to the user's mental model.
89
+ */
90
+ export async function appendChannelDotEnv(dir, kind, generated) {
91
+ const file = join(dir, ".env");
92
+ let current = "";
93
+ try {
94
+ current = await readFile(file, "utf8");
95
+ }
96
+ catch (e) {
97
+ if (e.code !== "ENOENT")
98
+ throw e;
99
+ }
100
+ const alreadySet = CHANNEL_SCAFFOLDS[kind].env.filter((e) => hasActiveEnvValue(current, e.name)).map((e) => e.name);
101
+ const lines = [];
102
+ const written = [];
103
+ const contentLines = current.split("\n");
104
+ let replacedInPlace = false;
105
+ for (const e of CHANNEL_SCAFFOLDS[kind].env) {
106
+ if (alreadySet.includes(e.name))
107
+ continue;
108
+ const value = generated[e.name];
109
+ if (value !== undefined) {
110
+ // An ACTIVE but EMPTY assignment already in the file (an uncommented, unfilled placeholder from
111
+ // `cp .env.example .env`) must be replaced IN PLACE: a new line written anywhere else either loses
112
+ // to it or wins by position under last-wins — both silently. Replace the LAST occurrence (the one
113
+ // the parser would honor). Line-level match uses THE parser, never a hand regex.
114
+ let idx = -1;
115
+ for (let i = contentLines.length - 1; i >= 0; i--) {
116
+ if (parseEnvContent(contentLines[i]).has(e.name)) {
117
+ idx = i;
118
+ break;
119
+ }
120
+ }
121
+ if (idx >= 0) {
122
+ contentLines[idx] = `${e.name}=${value}`;
123
+ replacedInPlace = true;
124
+ }
125
+ else {
126
+ lines.push(`${e.name}=${value}`);
127
+ }
128
+ written.push(e.name);
129
+ }
130
+ else if (!mentionsEnvName(current, e.name)) {
131
+ // Hint above, never inline after `=` — see appendChannelEnv (this IS the file loadEnvFile reads).
132
+ lines.push(`# ${e.hint}`, `# ${e.name}=`);
133
+ }
134
+ }
135
+ if (replacedInPlace) {
136
+ current = contentLines.join("\n");
137
+ await writeFile(file, current);
138
+ }
139
+ if (lines.length > 0) {
140
+ const marker = `# --- ${kind} channel ---`;
141
+ if (current.includes(marker)) {
142
+ // A marker already present (e.g. a .env copied from .env.example) — slot the new lines under it
143
+ // instead of orphaning them at the end of the file.
144
+ await writeFile(file, current.replace(marker, `${marker}\n${lines.join("\n")}`));
145
+ }
146
+ else {
147
+ const prefix = current === "" ? "" : current.endsWith("\n") ? "\n" : "\n\n";
148
+ await appendFile(file, `${prefix}${marker}\n${lines.join("\n")}\n`);
149
+ }
150
+ }
151
+ return { written, alreadySet };
152
+ }
153
+ /** The path `add <kind>` scaffolds to. */
154
+ function channelPath(dir, kind) {
155
+ return join(dir, "channels", `${kind}.ts`);
156
+ }
157
+ /** Whether a channel file already exists — checked before any mutation, so a no-clobber re-add is side-effect-free. */
158
+ export async function channelExists(dir, kind) {
159
+ return exists(channelPath(dir, kind));
160
+ }
161
+ /**
162
+ * Scaffold `channels/<kind>.ts` into {@link dir}. Never clobbers an existing file (the glue is
163
+ * authored content). The wx write is the TOCTOU safety net behind {@link channelExists}.
164
+ */
165
+ export async function scaffoldChannel(dir, kind) {
166
+ const channelsDir = join(dir, "channels");
167
+ // Don't write through a channels/ symlink that escapes the workspace; an in-workspace one is fine.
168
+ await assertInsideWorkspace(dir, "channels");
169
+ const file = channelPath(dir, kind);
170
+ if (await exists(file)) {
171
+ throw new Error(`${file} already exists — edit it, or remove it to re-scaffold`);
172
+ }
173
+ await mkdir(channelsDir, { recursive: true });
174
+ // `channel.ts` is THE adapter (→ channels/<kind>.ts); any other .ts in the bundle is a companion tool
175
+ // (→ tools/<name>, never clobbering an authored one).
176
+ for (const name of channelBundleFiles(kind)) {
177
+ const content = channelTemplate(kind, name);
178
+ if (name === "channel.ts") {
179
+ await writeFile(file, content, { flag: "wx" });
180
+ continue;
181
+ }
182
+ const toolFile = join(dir, "tools", name);
183
+ if (!(await exists(toolFile))) {
184
+ await mkdir(join(dir, "tools"), { recursive: true });
185
+ await writeFile(toolFile, content, { flag: "wx" });
186
+ }
187
+ }
188
+ return file;
189
+ }
190
+ /**
191
+ * Verify the workspace is ready to host a channel: an ESM package.json that declares
192
+ * `@fastagent-sh/fastagent` (the channel file imports it). `add` checks and guides, never bootstraps — that
193
+ * is `init`'s job.
194
+ */
195
+ export async function assertChannelReady(dir) {
196
+ const pkgPath = join(dir, "package.json");
197
+ let raw;
198
+ try {
199
+ raw = await readFile(pkgPath, "utf8");
200
+ }
201
+ catch (e) {
202
+ if (e.code === "ENOENT") {
203
+ // `dir` is where the kit lives (agentDir when set) — "run init" is only the right advice when no
204
+ // workspace exists yet; a kit missing its manifest (e.g. a --minimal init) needs the manifest, not init.
205
+ throw new Error(`${dir}: no package.json — a channel adapter is code and needs the kit's own manifest. ` +
206
+ `Run \`fastagent init\` for a fresh workspace, or add a package.json with @fastagent-sh/fastagent there ` +
207
+ `(a --minimal init has none)`);
208
+ }
209
+ throw e;
210
+ }
211
+ let pkg;
212
+ try {
213
+ pkg = JSON.parse(raw);
214
+ }
215
+ catch {
216
+ throw new Error(`${pkgPath}: invalid JSON`);
217
+ }
218
+ if (pkg.type !== "module") {
219
+ throw new Error(`${pkgPath}: fastagent channels are ESM — set "type": "module"`);
220
+ }
221
+ if (typeof pkg.dependencies?.["@fastagent-sh/fastagent"] !== "string") {
222
+ const add = detectRuntime(dir, pkg).runtime === "bun"
223
+ ? "bun add @fastagent-sh/fastagent"
224
+ : "npm install @fastagent-sh/fastagent";
225
+ throw new Error(`${pkgPath}: @fastagent-sh/fastagent is not a dependency — run \`${add}\` (the channel file imports it)`);
226
+ }
227
+ }
@@ -0,0 +1,54 @@
1
+ export interface ScaffoldOptions {
2
+ /** Scaffold the markdown-only unit (no package.json, no tool, no install) instead of a complete agent. */
3
+ minimal?: boolean;
4
+ /**
5
+ * Place the agent kit (persona.md/skills/tools/package.json) in this subdirectory (e.g. "./agent")
6
+ * instead of flat in `dir`; the config is written at the root with `agentDir` pointing there.
7
+ * Undefined = flat. The CLI decides (jurisdiction detection + flags); this stays mechanical.
8
+ */
9
+ agentDir?: string;
10
+ }
11
+ export interface ScaffoldResult {
12
+ dir: string;
13
+ /** Whether a complete (code-tool) agent was scaffolded (false for --minimal). */
14
+ complete: boolean;
15
+ /** The kit subdirectory in effect (relative, e.g. "./agent"); undefined = flat. */
16
+ agentDir?: string;
17
+ /** Files written by this run (relative paths). */
18
+ created: string[];
19
+ /** Files that already existed and were kept untouched (e.g. a pre-existing .gitignore). */
20
+ skipped: string[];
21
+ /** Kept ignore files appended with missing fastagent excludes (root .gitignore: .env/.fastagent, plus
22
+ * node_modules/ in the flat layout; kit .gitignore in the agentDir layout: node_modules/). */
23
+ patched: string[];
24
+ /** True if the target already had content before this run (init into an existing/non-empty dir). */
25
+ intoNonEmpty: boolean;
26
+ /** Non-fatal advisories the caller MUST surface. */
27
+ warnings: string[];
28
+ }
29
+ /**
30
+ * Jurisdiction signals: evidence that an existing system already claims this tree, so a flat agent kit
31
+ * would put each side's files under the other's jurisdiction (host tsc sweeps agent .ts; fastagent
32
+ * scans host tools/). Three classes, derived from the actual failure modes — a toolchain config, a
33
+ * deploy manifest, or fastagent's convention names already occupied. Any hit → the kit defaults into
34
+ * `./agent`. Deliberately NOT signals: "dir is non-empty", "has package.json", "has src/" — markdown
35
+ * and loose scripts are claimed by nobody, and "a directory is an agent" stays the default. Known
36
+ * tradeoff, decided for visibility: a HAND-BUILT agent dir (skills//tools/ authored for the agent, no
37
+ * config yet) also hits the occupation signal and defaults to ./agent — wrong for that case, but the
38
+ * reason is printed and `--flat` overrides; the reverse default would silently mis-scan a host's dirs.
39
+ */
40
+ export declare function detectHostSignals(dir: string): Promise<string[]>;
41
+ /** The `cd` target to show in `init`'s next-steps: the relative path when the target is inside `cwd`,
42
+ * the absolute path when it climbs out (a `../../..` is noise), or undefined when already in `cwd`. */
43
+ export declare function nextStepCd(cwd: string, dir: string): string | undefined;
44
+ /** Does a path exist? (async; shared with the sibling scaffold modules). */
45
+ export declare function exists(p: string): Promise<boolean>;
46
+ /**
47
+ * Scaffold a runnable workspace into {@link dir} (created if missing). Default is a complete agent
48
+ * (persona.md + the writing-great-skills skill + a code tool + package.json); `--minimal` drops the
49
+ * code tool and package.json. The kit goes flat into `dir`, or into {@link ScaffoldOptions.agentDir}
50
+ * (config at the root pointing there). Refuses only an existing fastagent.config.* (the ownership
51
+ * marker — already a workspace); every other pre-existing file (AGENTS.md, .gitignore, package.json)
52
+ * is kept, never overwritten — an existing AGENTS.md is the project's context, adopted as-is.
53
+ */
54
+ export declare function scaffoldWorkspace(dir: string, options?: ScaffoldOptions): Promise<ScaffoldResult>;