kward 0.83.0 → 0.85.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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
data/doc/transports.md CHANGED
@@ -104,6 +104,13 @@ Plugin-chat drivers may accept a `context:` keyword on `submit`. Transport turns
104
104
  provide the authenticated actor there. Existing drivers that do not accept the
105
105
  keyword continue to work, but cannot use actor-specific context.
106
106
 
107
+ The plugin-chat factory host reports `surface: :transport`, the stable chat type
108
+ ID and scope key, immutable plugin configuration, scoped durable storage, secret
109
+ lookup, logging, declared chat capabilities, and managed cleanup. Storage is
110
+ isolated by plugin, chat type, and external conversation scope. Versioned chat
111
+ contracts also reject attachments not declared by that chat type before calling
112
+ the driver.
113
+
107
114
  Plugin-chat transport IDs, turn events, transcript storage, and authorization
108
115
  remain separate from normal workspace sessions. A plugin's `singleton: :global`
109
116
  setting also means that all transport conversations share that one plugin
@@ -119,6 +126,14 @@ API.
119
126
  Transports that cannot support interactive approvals must use an explicit
120
127
  configured fallback policy; they must not leave an agent turn waiting forever.
121
128
 
129
+ Structured plugin UI requests use the same transport-neutral interaction path.
130
+ Plugin `question`, `select`, `confirm`, and `input` calls arrive with matching
131
+ interaction kinds, prompts, choices, and metadata, and answers are routed back to the
132
+ waiting plugin handler. An adapter may support only a subset, but it must cancel
133
+ or answer unsupported requests rather than leaving the turn blocked. Plugin
134
+ notifications and progress remain local to the RPC/TUI UI bridge for now and
135
+ are not transport interaction requests.
136
+
122
137
  ## Storage and routing
123
138
 
124
139
  Transport plugins receive namespaced durable storage for state such as:
@@ -10,14 +10,24 @@ This page covers common issues and how to diagnose them. When something is not w
10
10
  kward doctor
11
11
  ```
12
12
 
13
- It checks that your config file is readable and valid JSON, that the config and session directories are writable, that the workspace exists, which provider and model are active, and which credentials are configured. Use it as the first diagnostic step for any unexpected behavior.
13
+ It checks that your config file is readable and valid JSON, that the config and session directories are writable, that the workspace exists, which provider and model are active, and which credentials are configured. Core check failures produce a nonzero exit status, so the command can also guard setup scripts. Optional features such as Pan are reported separately and do not make an otherwise healthy setup fail.
14
14
 
15
- `kward auth status` gives a focused view of credentials only, also without printing secrets:
15
+ `kward auth status` gives a focused view of configured credentials only, also without printing secrets:
16
16
 
17
17
  ```bash
18
18
  kward auth status
19
19
  ```
20
20
 
21
+ Use `kward auth status --all` when you also want to see every unconfigured provider.
22
+
23
+ Kward keeps normal command failures concise. To include a Ruby backtrace while diagnosing an unexpected failure, rerun the command with debug errors enabled:
24
+
25
+ ```bash
26
+ KWARD_DEBUG=1 kward "Repeat the failing task"
27
+ ```
28
+
29
+ Backtraces can include local paths and implementation details. Review them before sharing them publicly.
30
+
21
31
  ## Authentication errors and token expiration
22
32
 
23
33
  OAuth tokens expire. Kward refreshes access tokens automatically when a refresh token is available, but if the refresh token is missing or expired, requests fail with a provider-specific error:
data/doc/usage.md CHANGED
@@ -66,7 +66,7 @@ Inside interactive mode, ask Kward to run a command:
66
66
  Run the focused test for the CLI status command.
67
67
  ```
68
68
 
69
- Or run an interactive PTY command yourself from the composer by prefixing it with `!`. Aliases configured in `ekwsh.yml` are expanded here too:
69
+ Or run an interactive PTY command yourself from the composer by prefixing it with `!`. Aliases configured in `kwshrc` are expanded here too:
70
70
 
71
71
  ```text
72
72
  !git status --short
@@ -78,7 +78,7 @@ For several commands, enter the embedded Kward shell:
78
78
  /shell
79
79
  ```
80
80
 
81
- `/shell` opens `ekwsh`, a Kward-native command mode with one persistent local shell process. It preserves state such as the current directory, environment variables, functions, and aliases between commands. Prefix a line with `?` inside `/shell` to ask a transient shell assistant about the latest output, execute an explicit state change, or prepare a command without running it. External commands receive an interactive PTY by default, so `git log`, `less`, Vim, SSH, and REPLs work without a prefix. Use `capture <command>` inside `/shell` or `/capture <command>` from the normal composer for bounded, transcript-friendly output. See [Embedded shell](shell.md) for built-ins, completion, configuration, ANSI handling, PTY passthrough, and limitations.
81
+ `/shell` opens `kwsh`, a Kward-native command mode with one persistent local shell process. It preserves state such as the current directory, environment variables, functions, and aliases between commands. Prefix a line with `?` inside `/shell` to ask a transient shell assistant about the latest output, execute an explicit state change, or prepare a command without running it. External commands receive an interactive PTY by default, so `git log`, `less`, Vim, SSH, and REPLs work without a prefix. Use `capture <command>` inside `/shell` or `/capture <command>` from the normal composer for bounded, transcript-friendly output. See [Embedded shell](shell.md) for built-ins, completion, configuration, ANSI handling, PTY passthrough, and limitations.
82
82
 
83
83
  ## Shell commands
84
84
 
@@ -136,11 +136,14 @@ Slash commands run local actions in the current session. Most do not send a prom
136
136
  | `/tab close` | close the active tab. |
137
137
  | `/tab new` | open a new tab. |
138
138
  | `/tab name <label>` | rename the active tab label. |
139
+ | `/name <name>` | rename the active session and tab together; available while the tab's agent is running. |
139
140
  | `/tab worktree` | create or activate the active session tab's linked Git worktree. |
140
141
  | `/tab worktree activate` | explicitly create or activate the active session tab's linked Git worktree. |
141
142
  | `/tab worktree detach` | return to the original workspace while keeping the linked worktree and branch. |
142
143
  | `/tab worktree status` | inspect the active tab's worktree binding and changes. |
143
144
  | `/tab worktree merge` | merge a clean worktree branch into the branch checked out in its original workspace. |
145
+ | `/tab worktree merge resolve` | ask the agent to resolve an in-progress merge in the original workspace from the same tab. |
146
+ | `/tab worktree merge continue` | stage resolutions and complete an in-progress merge after confirmation. |
144
147
  | `/tab worktree merge abort` | abort a conflicted worktree merge in the original workspace. |
145
148
  | `/tab worktree remove` | remove a clean linked worktree while keeping its branch. |
146
149
  | `/worktree …` | alias for `/tab worktree …` on the active tab. |
@@ -160,7 +163,7 @@ Slash commands run local actions in the current session. Most do not send a prom
160
163
  | `/skill <name>` | activate a configured skill explicitly for the current session. |
161
164
  | `/stats [range]` | summarize enabled local telemetry. |
162
165
  | `/hooks ...` | inspect, diagnose, trust, or untrust lifecycle hooks. |
163
- | `/scratchpad [text|markdown|ruby]` | open an unsaved editor buffer. |
166
+ | `/scratchpad [language|help]` | open an unsaved, syntax-highlighted editor buffer; `/scratchpad` alone opens plain text. |
164
167
  | `/redraw` | fix terminal drawing after resize or glitches. |
165
168
  | `/reload` | reload installed plugins. |
166
169
  | `/exit` | leave Kward. |
@@ -191,7 +194,7 @@ Use sessions when work spans more than one terminal sitting, or when you want to
191
194
  Typical flow:
192
195
 
193
196
  ```text
194
- /rename oauth cleanup
197
+ /name oauth cleanup
195
198
  # work with Kward
196
199
  /export oauth-notes.md
197
200
  /exit
@@ -263,13 +266,13 @@ This screenshot shows the broken layout. Find the likely CSS issue.
263
266
 
264
267
  ## Pan mode
265
268
 
266
- Pan mode starts a mobile-friendly LAN web UI:
269
+ Pan mode starts a mobile-friendly local web UI:
267
270
 
268
271
  ```bash
269
272
  kward --working-directory ~/code/project pan
270
273
  ```
271
274
 
272
- Use it only on trusted networks. It exposes file, shell, web, and configured extension tools through a browser UI and requires credentials configured in `config.json`. Pan saves conversations to the normal workspace session store; its session drawer can create, resume, rename, and delete sessions. Session changes are disabled while turns are active or queued. See [Pan mode](pan.md) for setup, browser workflows, security, and limitations.
275
+ Pan binds to loopback by default. Exposing it to a trusted LAN requires an explicit host setting and prints a warning because Pan uses plain HTTP. It exposes file, shell, web, and configured extension tools through a browser UI and requires a configured username plus a config or environment password. Pan saves conversations to the normal workspace session store; its session drawer can create, resume, rename, and delete sessions. Session changes are disabled while turns are active or queued. See [Pan mode](pan.md) for setup, browser workflows, security, and limitations.
273
276
 
274
277
  ## RPC backend
275
278
 
@@ -21,6 +21,15 @@ Important behavior:
21
21
  - Edits use exact text replacement, so accidental partial or fuzzy changes fail instead of guessing.
22
22
  - With sandboxing off (the default), shell commands run as your operating-system user from the workspace. Enable [command sandboxing](sandboxing.md) to apply an OS boundary to model-requested `run_shell_command` workers. Command output is capped at 128 KB.
23
23
 
24
+ ### Worktree targets
25
+
26
+ In an active linked-worktree tab, `list_directory`, `read_file`, `write_file`, `edit_file`, `run_shell_command`, `summarize_file_structure`, and `context_for_task` also advertise an optional `target` argument:
27
+
28
+ - `active` is the default linked worktree.
29
+ - `origin` is the tab's verified original repository worktree.
30
+
31
+ Kward supplies this fixed role map from the tab binding; the model cannot provide an arbitrary filesystem root. Selecting `origin` does not add a separate approval prompt, so the agent can complete a requested inspect/edit/test/commit workflow without interruption. The normal configured tool-permission policy still applies equally to both targets. Each target keeps its own path guard and strict shell sandbox.
32
+
24
33
  ## Reading the workspace
25
34
 
26
35
  ### `list_directory`
@@ -15,7 +15,7 @@
15
15
  # The game renders colored sprites and particle-burst explosions inside the
16
16
  # composer canvas region using the interactive mode API.
17
17
 
18
- Kward.plugin do |plugin|
18
+ Kward.plugin(id: "com.kward.example.space-invaders", version: "1.0.0", api: 1) do |plugin|
19
19
  plugin.interactive_command "invaders", rows: 18, fps: 30, description: "Space Invaders arcade game" do |ui, ctx|
20
20
  game = SpaceInvadersGame.new(width: ui.width, height: ui.height)
21
21
  ui.on_tick { |ui| game.tick(ui) }
@@ -1,6 +1,6 @@
1
1
  # Displays the current Federation stardate in Kward's interactive footer.
2
- Kward.plugin do |plugin|
3
- plugin.footer do |_ctx|
2
+ Kward.plugin(id: "com.kward.example.stardate-footer", version: "1.0.0", api: 1) do |plugin|
3
+ plugin.status "stardate", priority: :low do |_ctx|
4
4
  now = Time.now.utc
5
5
  reference = Time.utc(1987, 7, 15)
6
6
  stardate = 41_000 + ((now - reference) / (365.25 * 24 * 60 * 60) * 1_000)
@@ -1,6 +1,6 @@
1
1
  require_relative "telegram_transport"
2
2
 
3
- Kward.plugin do |plugin|
3
+ Kward.plugin(id: "com.kward.telegram", version: "1.0.0", api: 1) do |plugin|
4
4
  capabilities = {
5
5
  inbound: %i[text],
6
6
  outbound: %i[text],
data/kward.gemspec CHANGED
@@ -6,16 +6,16 @@ Gem::Specification.new do |spec|
6
6
  spec.authors = ["Kai Wood"]
7
7
  spec.email = ["kai.wood@icloud.com"]
8
8
 
9
- spec.summary = "An extendable Ruby CLI coding agent."
10
- spec.description = "Kward is a Ruby CLI coding agent with local workspace tools, configurable prompts, web search, sessions, and an experimental JSON-RPC backend."
11
- spec.homepage = "https://github.com/kaiwood/kward"
9
+ spec.summary = "An extensible Ruby coding agent for your terminal."
10
+ spec.description = "Kward is an extensible Ruby coding agent with workspace tools, resumable sessions, multiple model providers, a local browser UI, and JSON-RPC integrations."
11
+ spec.homepage = "https://kaiwood.github.io/kward/"
12
12
  spec.license = "MIT"
13
13
  spec.required_ruby_version = ">= 3.4"
14
14
 
15
15
  spec.metadata["rubygems_mfa_required"] = "true"
16
16
  spec.metadata["source_code_uri"] = "https://github.com/kaiwood/kward"
17
17
  spec.metadata["changelog_uri"] = "https://github.com/kaiwood/kward/blob/main/CHANGELOG.md"
18
- spec.metadata["documentation_uri"] = "https://github.com/kaiwood/kward#readme"
18
+ spec.metadata["documentation_uri"] = "https://kaiwood.github.io/kward/"
19
19
  spec.metadata["bug_tracker_uri"] = "https://github.com/kaiwood/kward/issues"
20
20
 
21
21
  spec.files = Dir.chdir(__dir__) do
@@ -34,4 +34,5 @@ Gem::Specification.new do |spec|
34
34
  spec.add_dependency "tty-prompt"
35
35
  spec.add_dependency "tty-reader"
36
36
  spec.add_dependency "tty-screen"
37
+ spec.add_dependency "unicode-display_width"
37
38
  end
data/lib/kward/agent.rb CHANGED
@@ -3,6 +3,7 @@ require_relative "model/chat_invocation"
3
3
  require_relative "compactor"
4
4
  require_relative "model/context_overflow"
5
5
  require_relative "conversation"
6
+ require_relative "plugins/turn_request"
6
7
  require_relative "events"
7
8
  require_relative "deep_copy"
8
9
  require_relative "hooks"
@@ -28,10 +29,11 @@ module Kward
28
29
  # lowest layer that owns the behavior, and use `Agent` only for cross-step turn
29
30
  # coordination.
30
31
  class Agent
31
- def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: nil, warning_sink: nil, hook_manager: nil, hook_context: nil)
32
+ def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: nil, warning_sink: nil, hook_manager: nil, hook_context: nil, strict_provider: false)
32
33
  @client = client
33
34
  @tool_registry = tool_registry
34
35
  @conversation = conversation
36
+ @strict_provider = strict_provider == true
35
37
  @warning_sink = warning_sink
36
38
  @telemetry_logger = telemetry_logger || TelemetryLogger.new(warning_sink: warning_sink)
37
39
  @hook_manager = hook_manager
@@ -40,9 +42,9 @@ module Kward
40
42
 
41
43
  attr_reader :conversation, :tool_registry
42
44
 
43
- # Adds a user message, compacts context when needed, and runs the turn.
45
+ # Adds user input or scopes a plugin system request, then runs a normal turn.
44
46
  #
45
- # @param input [String] text sent to the model
47
+ # @param input [String, PluginTurnRequest] user text or host-staged system instructions
46
48
  # @param display_input [String, nil] alternate text kept for transcripts
47
49
  # @yieldparam event [Object] streamed turn event for frontends
48
50
  # @return [String] final assistant answer
@@ -51,19 +53,31 @@ module Kward
51
53
  status = "completed"
52
54
  error = nil
53
55
  cancellation&.raise_if_cancelled!
54
- turn_start = run_hook("turn_start", payload: { input: input, display_input: display_input })
56
+ system_turn = input if input.is_a?(PluginTurnRequest)
57
+ display_input ||= system_turn.to_s if system_turn
58
+ input = system_turn.system if system_turn
59
+ turn_start = run_hook("turn_start", payload: { input: input, display_input: display_input, input_role: system_turn ? "system" : "user" })
55
60
  return hook_denied_answer(turn_start) if turn_start.denied?
56
61
 
57
62
  input = turn_start.payload[:input] || turn_start.payload["input"] || input
58
63
  display_input = turn_start.payload[:display_input] || turn_start.payload["display_input"] || display_input
59
- @conversation.refresh_system_message_if_workspace_agents_changed!
60
- @conversation.append_user(input, display_content: display_input)
61
- run_hook("turn_context_build_before", payload: { message_count: @conversation.messages.length })
62
- auto_compact_if_needed
63
- run_hook("turn_context_build_after", payload: { message_count: @conversation.messages.length })
64
- answer = run_turn(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering, options: options, tool_registry: tool_registry, &block)
65
- run_hook("turn_end", payload: { input: input, answer: answer })
66
- answer
64
+ if system_turn
65
+ system_turn = PluginTurnRequest.new(system: input, command: system_turn.command, plugin_id: system_turn.plugin_id, id: system_turn.id)
66
+ end
67
+ @conversation.with_system_turn(system_turn) do
68
+ @conversation.refresh_system_message_if_workspace_agents_changed!
69
+ if system_turn
70
+ @conversation.append_system_turn(system_turn)
71
+ else
72
+ @conversation.append_user(input, display_content: display_input)
73
+ end
74
+ run_hook("turn_context_build_before", payload: { message_count: @conversation.messages.length })
75
+ auto_compact_if_needed
76
+ run_hook("turn_context_build_after", payload: { message_count: @conversation.messages.length })
77
+ answer = run_turn(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering, options: options, tool_registry: tool_registry, &block)
78
+ run_hook("turn_end", payload: { input: input, answer: answer })
79
+ answer
80
+ end
67
81
  rescue StandardError => e
68
82
  status = "failed"
69
83
  error = e
@@ -241,7 +255,8 @@ module Kward
241
255
  tools: registry.schemas,
242
256
  provider: options[:provider] || @conversation.provider,
243
257
  model: options[:model] || @conversation.model,
244
- reasoning: options[:reasoning] || @conversation.reasoning_effort
258
+ reasoning: options[:reasoning] || @conversation.reasoning_effort,
259
+ provider_required: @strict_provider
245
260
  }
246
261
  before = run_hook("model_request_before", payload: request)
247
262
  request = DeepCopy.merge(request, before.payload) if before.decision.modify?
@@ -261,7 +276,8 @@ module Kward
261
276
  steering: steering,
262
277
  provider: request[:provider] || request["provider"],
263
278
  model: request[:model] || request["model"],
264
- reasoning: request[:reasoning] || request["reasoning"]
279
+ reasoning: request[:reasoning] || request["reasoning"],
280
+ provider_required: request[:provider_required] || request["provider_required"]
265
281
  }
266
282
  )
267
283
  run_hook("model_response_after_parse", payload: { message: response })
@@ -13,6 +13,8 @@ module Kward
13
13
  case arguments
14
14
  when ["status"]
15
15
  print_auth_status
16
+ when ["status", "--all"]
17
+ print_auth_status(show_all: true)
16
18
  when ["logout"]
17
19
  logout_auth
18
20
  else
@@ -21,23 +23,42 @@ module Kward
21
23
  end
22
24
 
23
25
  # Writes the auth status output for the terminal CLI flow.
24
- def print_auth_status
25
- store = api_key_store
26
- store.migrate_openrouter_config_key!
27
- lines = ["#{colored("Auth Status", :green, :bold)}", ""]
28
- lines << auth_status_line("OpenAI OAuth", File.exist?(OpenAIOAuth.default_auth_path), OpenAIOAuth.default_auth_path)
29
- lines << auth_status_line("Anthropic OAuth", File.exist?(AnthropicOAuth.default_auth_path), AnthropicOAuth.default_auth_path)
30
- lines << auth_status_line("GitHub OAuth", File.exist?(GithubOAuth.default_auth_path), GithubOAuth.default_auth_path)
31
- ProviderCatalog.api_key_providers.each do |provider|
32
- lines << auth_status_line("#{provider.name} API key", store.configured?(provider.id), store.path)
26
+ def print_auth_status(show_all: false)
27
+ credentials = auth_credentials
28
+ configured, missing = credentials.partition { |credential| credential.fetch(:configured) }
29
+ lines = [colored("Authentication", :green, :bold), "", colored("Configured", :blue, :bold)]
30
+ lines.concat(auth_credential_lines(configured, status: :ok, empty_message: "None"))
31
+
32
+ if show_all
33
+ lines << ""
34
+ lines << colored("Not configured", :blue, :bold)
35
+ lines.concat(auth_credential_lines(missing, status: :optional, empty_message: "None"))
36
+ elsif missing.any?
37
+ lines << ""
38
+ lines << "#{missing.length} other provider#{missing.length == 1 ? " is" : "s are"} not configured. Run `kward auth status --all` for details."
33
39
  end
40
+
41
+ lines << ""
42
+ lines << colored("Credential directory", :blue, :bold)
43
+ lines << " #{ConfigFiles.config_dir}"
34
44
  @prompt.say lines.join("\n")
35
45
  end
36
46
 
37
- def auth_status_line(label, configured, location)
38
- status = configured ? :ok : :warning
39
- message = configured ? "configured" : "not configured"
40
- "#{doctor_mark(status)} #{label}: #{message} (#{location})"
47
+ def auth_credentials
48
+ store = api_key_store
49
+ store.migrate_openrouter_config_key!
50
+ credentials = [
51
+ { label: "OpenAI OAuth", configured: File.exist?(OpenAIOAuth.default_auth_path) },
52
+ { label: "Anthropic OAuth", configured: File.exist?(AnthropicOAuth.default_auth_path) },
53
+ { label: "GitHub OAuth", configured: File.exist?(GithubOAuth.default_auth_path) }
54
+ ]
55
+ credentials.concat ProviderCatalog.api_key_providers.map { |provider| { label: "#{provider.name} API key", configured: store.configured?(provider.id) } }
56
+ end
57
+
58
+ def auth_credential_lines(credentials, status:, empty_message:)
59
+ return [" #{empty_message}"] if credentials.empty?
60
+
61
+ credentials.map { |credential| " #{doctor_mark(status)} #{credential.fetch(:label)}" }
41
62
  end
42
63
 
43
64
  def logout_auth
@@ -38,67 +38,88 @@ module Kward
38
38
 
39
39
  # Writes the help output for the terminal CLI flow.
40
40
  def print_help
41
- command = ->(text) { colored(text, :green, :bold) }
42
- option = ->(text) { colored(text, :cyan) }
43
41
  heading = ->(text) { colored(text, :blue, :bold) }
42
+ lines = ["#{colored("Kward", :green, :bold)} - an extensible CLI coding agent", ""]
44
43
 
45
- @prompt.say <<~HELP.rstrip
46
- #{colored("Kward", :green, :bold)} - an extendable CLI coding agent
47
-
48
- #{heading.call("Usage")}
49
- #{command.call("kward")} Start an interactive chat
50
- #{command.call("kward")} #{option.call('"Explain this project"')} Run a one-shot prompt
51
- #{command.call("kward --filter")} #{option.call('"Translate"')} Filter stdin with an instruction
52
- #{command.call("kward login")} Sign in or save provider credentials
53
- #{command.call("kward auth status")} Show saved credential status
54
- #{command.call("kward init")} Install starter prompts and PRINCIPLES.md
55
- #{command.call("kward doctor")} Check local Kward setup
56
- #{command.call("kward hooks doctor")} Inspect lifecycle hook setup
57
- #{command.call("kward skills status")} Inspect project skill trust
58
- #{command.call("kward edit")} #{option.call("<filename>")} Open a file in the integrated editor
59
- #{command.call("kward sysprompt")} Inspect the effective system prompt
60
- #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
61
- #{command.call("kward pan")} Start Pan mode web UI
62
- #{command.call("kward rpc")} Start the JSON-RPC backend
63
- #{command.call("kward transport")} Manage transport plugins
64
-
65
- #{heading.call("Commands")}
66
- #{command.call("help")} Show this help
67
- #{command.call("version")} Show the installed Kward version
68
- #{command.call("login")} [anthropic|openrouter|github] Sign in with OpenAI, Anthropic, OpenRouter, or GitHub
69
- #{command.call("auth status|logout")} Show or clear saved credentials
70
- #{command.call("init")} Install starter prompts and PRINCIPLES.md
71
- #{command.call("doctor")} Check local Kward setup
72
- #{command.call("hooks list|events|logs|doctor|trust|untrust")} Inspect lifecycle hooks
73
- #{command.call("skills status|trust|untrust|review")} Manage project skill trust
74
- #{command.call("edit")} #{option.call("<filename>")} Open a file in the integrated editor
75
- #{command.call("sysprompt")} [--raw] Inspect the effective system prompt
76
- #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
77
- #{command.call("openrouter refresh|list")} Refresh or list cached OpenRouter models
78
- #{command.call("pan")} Start Pan mode web UI
79
- #{command.call("rpc")} Run the JSON-RPC backend for UI clients
80
- #{command.call("transport list|status|run")} Manage transport plugins
81
-
82
- #{heading.call("Options")}
83
- #{option.call("--working-directory=PATH")} Run Kward from PATH
84
- #{option.call("--mode=MODE")} Execution mode: auto, chat, oneshot, filter
85
- #{option.call("--filter")} Shortcut for --mode filter
86
- #{option.call("--skip-config")} Ignore the main config file for this run
87
- #{option.call("--help")}, #{option.call("-h")} Show this help
88
- #{option.call("--version")}, #{option.call("-v")} Show the installed version
89
-
90
- #{heading.call("Examples")}
91
- #{command.call("kward")}
92
- #{command.call("kward")} #{option.call('"Explain this project"')}
93
- #{command.call("git diff | kward")} #{option.call('"Summarize the main changes"')}
94
- #{command.call("echo Hello | kward --filter")} #{option.call('"Translate to German"')}
95
- #{command.call("kward login openrouter")}
96
- #{command.call("kward edit lib/main.rb")}
97
- #{command.call("kward openrouter refresh")}
98
- #{command.call("kward stats tokens today --bucket hour")}
99
-
100
- Command names take precedence. Anything else is sent as a one-shot prompt.
101
- HELP
44
+ help_sections.each do |title, entries|
45
+ lines << heading.call(title)
46
+ lines.concat formatted_help_rows(entries, color: :green, bold: true)
47
+ lines << ""
48
+ end
49
+
50
+ lines << heading.call("Options")
51
+ lines.concat formatted_help_rows(help_options, color: :cyan)
52
+ lines << ""
53
+ lines << heading.call("Examples")
54
+ lines.concat help_examples.map { |example| " #{colored(example, :green, :bold)}" }
55
+ lines << ""
56
+ lines << "Command names take precedence. Anything else is sent as a one-shot prompt."
57
+ @prompt.say lines.join("\n")
58
+ end
59
+
60
+ def help_sections
61
+ {
62
+ "Getting started" => [
63
+ ["kward", "Start an interactive chat"],
64
+ ["kward login [PROVIDER]", "Sign in or save provider credentials"],
65
+ ["kward doctor", "Check local Kward setup"],
66
+ ["kward init", "Install starter prompts and PRINCIPLES.md"]
67
+ ],
68
+ "Work" => [
69
+ ["kward \"PROMPT\"", "Run a one-shot prompt"],
70
+ ["kward --filter \"INSTRUCTION\"", "Filter standard input"],
71
+ ["kward edit <filename>", "Open a file in the integrated editor"],
72
+ ["kward sysprompt [--raw]", "Inspect the effective system prompt"]
73
+ ],
74
+ "Manage" => [
75
+ ["kward auth status [--all]", "Show saved credential status"],
76
+ ["kward hooks <command>", "Inspect lifecycle hooks"],
77
+ ["kward skills <command>", "Manage project skill trust"],
78
+ ["kward openrouter <command>", "Manage cached OpenRouter models"],
79
+ ["kward stats tokens [range] [options]", "Export local token telemetry as CSV"]
80
+ ],
81
+ "Integrate" => [
82
+ ["kward pan", "Start the local Pan web UI"],
83
+ ["kward rpc", "Start the JSON-RPC backend"],
84
+ ["kward transport <command>", "Manage transport plugins"]
85
+ ],
86
+ "Reference" => [
87
+ ["kward help [command]", "Show help"],
88
+ ["kward version", "Show the installed version"]
89
+ ]
90
+ }
91
+ end
92
+
93
+ def help_options
94
+ [
95
+ ["--working-directory=PATH", "Run Kward from PATH"],
96
+ ["--mode=MODE", "Execution mode: auto, chat, oneshot, filter"],
97
+ ["--filter", "Shortcut for --mode filter"],
98
+ ["--skip-config", "Ignore the main config file for this run"],
99
+ ["--help, -h", "Show help"],
100
+ ["--version, -v", "Show the installed version"]
101
+ ]
102
+ end
103
+
104
+ def help_examples
105
+ [
106
+ "kward",
107
+ "kward \"Explain this project\"",
108
+ "git diff | kward \"Summarize the main changes\"",
109
+ "echo Hello | kward --filter \"Translate to German\"",
110
+ "kward login openrouter",
111
+ "kward edit lib/main.rb",
112
+ "kward stats tokens today --bucket hour"
113
+ ]
114
+ end
115
+
116
+ def formatted_help_rows(entries, color:, bold: false)
117
+ width = entries.map { |label, _description| label.length }.max
118
+ styles = [color]
119
+ styles << :bold if bold
120
+ entries.map do |label, description|
121
+ " #{colored(label.ljust(width), *styles)} #{description}"
122
+ end
102
123
  end
103
124
 
104
125
  def command_help
@@ -119,9 +140,9 @@ module Kward
119
140
  examples: ["kward login", "kward login anthropic", "kward login openrouter", "kward login github"]
120
141
  },
121
142
  "auth" => {
122
- usage: "kward auth status|logout",
143
+ usage: "kward auth status [--all]|logout",
123
144
  description: "Show or clear saved provider credentials without printing secrets.",
124
- examples: ["kward auth status", "kward auth logout"]
145
+ examples: ["kward auth status", "kward auth status --all", "kward auth logout"]
125
146
  },
126
147
  "init" => {
127
148
  usage: "kward init",
@@ -165,7 +186,7 @@ module Kward
165
186
  },
166
187
  "pan" => {
167
188
  usage: "kward pan",
168
- description: "Start Pan mode, a mobile-friendly LAN web UI with persistent sessions.",
189
+ description: "Start Pan mode, a mobile-friendly local web UI with persistent sessions.",
169
190
  examples: ["kward pan", "kward --working-directory ~/code/project pan"]
170
191
  },
171
192
  "rpc" => {
@@ -6,7 +6,7 @@ module Kward
6
6
  module CompactionCommands
7
7
  private
8
8
 
9
- def compact_context(agent, argument)
9
+ def compact_context(agent, argument, cancellation: nil)
10
10
  before = run_lifecycle_hook("session_compact_before", conversation: agent.conversation, payload: { instructions: argument.to_s })
11
11
  if before.denied? || before.approval_required?
12
12
  runtime_output("Declined: #{before.decision.message || "session compaction denied"}")
@@ -17,16 +17,22 @@ module Kward
17
17
  conversation: agent.conversation,
18
18
  client: @client,
19
19
  tool_result_summarizer: lambda { |tool_call, content| tool_result_summary(tool_call, content) }
20
- ).compact(custom_instructions: argument)
20
+ ).compact(custom_instructions: argument, cancellation: cancellation)
21
21
  run_lifecycle_hook("session_compact_after", conversation: agent.conversation, payload: { old_message_count: result.old_message_count, new_message_count: result.new_message_count })
22
22
  runtime_output("Compacted context: #{result.old_message_count} messages -> #{result.new_message_count} messages.")
23
- render_transcript_block("Assistant", result.summary)
23
+ result.summary
24
24
  rescue Compactor::NothingToCompact, Compactor::AlreadyCompacted, Compactor::EmptySummary => e
25
25
  runtime_output(e.message)
26
+ rescue Cancellation::CancelledError
27
+ raise
26
28
  rescue StandardError => e
27
29
  runtime_output("Compaction error: #{e.message}")
28
30
  end
29
31
 
32
+ def render_compaction_summary(summary)
33
+ render_transcript_block("Assistant", summary)
34
+ end
35
+
30
36
  end
31
37
  end
32
38
  end