Compare commits

...

19 Commits

Author SHA1 Message Date
Gregory Schier
0cab111965 Refresh Git dropdown data on open and fetch periodically
- Add refreshKey to useGit queries so dropdown data refreshes on open
- Convert fetchAll from mutation to query with 10-minute refetch interval
- Re-run status query after fetchAll completes via dataUpdatedAt key
- Use placeholderData to keep previous data during key changes
- Remove disabled state from Push, Pull, and Commit menu items
2026-02-10 15:33:42 -08:00
Gregory Schier
7fef35ce0a Ship metainfo in deb, remove from Flatpak manifest 2026-02-10 15:26:40 -08:00
Gregory Schier
654af09951 Bump GNOME runtime to 49, fix corrupted arm64 SHA256 2026-02-10 15:22:51 -08:00
Gregory Schier
484dcfade0 Add Flatpak and Flathub packaging support (#388) 2026-02-10 14:38:40 -08:00
Gregory Schier
fda18c5434 Snapshot faker template function names in test
Replace the brittle count assertion (toBe(226)) with a snapshot of all
exported function names. This catches accidental additions, removals,
or renames across faker upgrades with a clear diff.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 10:22:03 -08:00
Gregory Schier
a8176d6e9e Skip disabled key-value entries during request rendering
Skip disabled headers, metadata, URL parameters, and form body
entries in the render phase for HTTP, gRPC, and WebSocket requests.
Previously, disabled entries were still template-rendered even though
they were filtered out later at the use site.
2026-02-09 10:17:43 -08:00
Gregory Schier
957d8d9d46 Move faker plugin from external to bundled 2026-02-09 08:43:49 -08:00
Gregory Schier
5f18bf25e2 Replace shell-quote with shlex for curl import (#387) 2026-02-09 08:22:11 -08:00
Gregory Schier
66942eaf2c Update httpsnippet plugin README and bump to v1.0.3 2026-02-07 15:20:20 -08:00
Zhizhen He
38796b1833 feat: add delete folder and copy id actions to folder settings (#380) 2026-02-07 08:48:38 -08:00
dependabot[bot]
49ffa6fc45 Bump bytes from 1.10.1 to 1.11.1 (#379)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-07 08:48:03 -08:00
Brijmohan Siyag
1f56ba2eb6 Fix text input autocomplete not working with completionOptions (#368) 2026-02-07 08:47:50 -08:00
Gregory Schier
f98a70ecb4 Add dynamic() support to prompt.form() plugin API (#386) 2026-02-07 08:09:40 -08:00
dependabot[bot]
2984eb40c9 Bump time from 0.3.41 to 0.3.47 (#385)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 16:50:40 -08:00
Gregory Schier
cc5d4742f0 Don't select current request by default for response chaining 2026-02-05 13:08:08 -08:00
Gregory Schier
5b8e4b98a0 Use "send" preview mode for copy-as-curl 2026-02-05 08:31:28 -08:00
Gregory Schier
8637c90a21 Fix CSV responses ignoring raw view mode 2026-02-05 07:57:12 -08:00
dependabot[bot]
b88c5e71a0 Bump @modelcontextprotocol/sdk from 1.25.2 to 1.26.0 (#383)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 16:54:01 -08:00
dependabot[bot]
1899d512ab Bump git2 from 0.20.2 to 0.20.4 (#384)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 16:53:50 -08:00
49 changed files with 1952 additions and 248 deletions

43
.github/workflows/flathub.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Update Flathub
on:
release:
types: [published]
permissions:
contents: read
jobs:
update-flathub:
name: Update Flathub manifest
runs-on: ubuntu-latest
# Only run for stable releases (skip betas/pre-releases)
if: ${{ !github.event.release.prerelease }}
steps:
- name: Checkout app repo
uses: actions/checkout@v4
- name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}"
- name: Checkout Flathub repo
uses: actions/checkout@v4
with:
repository: flathub/app.yaak.Yaak
token: ${{ secrets.FLATHUB_TOKEN }}
path: flathub-repo
- name: Copy updated files to Flathub repo
run: |
cp flatpak/app.yaak.Yaak.yml flathub-repo/
cp LICENSE flathub-repo/
sed -i 's|path: \.\./LICENSE|path: LICENSE|' flathub-repo/app.yaak.Yaak.yml
- name: Commit and push to Flathub
working-directory: flathub-repo
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update to ${{ github.event.release.tag_name }}"
git push

4
.gitignore vendored
View File

@@ -44,3 +44,7 @@ crates-tauri/yaak-app/tauri.worktree.conf.json
# Tauri auto-generated permission files
**/permissions/autogenerated
**/permissions/schemas
# Flatpak build artifacts
flatpak-repo/
.flatpak-builder/

36
Cargo.lock generated
View File

@@ -689,9 +689,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.10.1"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
dependencies = [
"serde",
]
@@ -1316,12 +1316,12 @@ checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
[[package]]
name = "deranged"
version = "0.4.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
dependencies = [
"powerfmt",
"serde",
"serde_core",
]
[[package]]
@@ -2136,9 +2136,9 @@ dependencies = [
[[package]]
name = "git2"
version = "0.20.2"
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110"
checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
dependencies = [
"bitflags 2.9.1",
"libc",
@@ -3036,9 +3036,9 @@ dependencies = [
[[package]]
name = "libgit2-sys"
version = "0.18.1+1.9.0"
version = "0.18.3+1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1dcb20f84ffcdd825c7a311ae347cce604a6f084a767dec4a4929829645290e"
checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487"
dependencies = [
"cc",
"libc",
@@ -3446,9 +3446,9 @@ checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
[[package]]
name = "num-conv"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-traits"
@@ -6341,9 +6341,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.41"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
@@ -6351,22 +6351,22 @@ dependencies = [
"num-conv",
"num_threads",
"powerfmt",
"serde",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.22"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",

View File

@@ -12,7 +12,7 @@ use chrono::Utc;
use cookie::Cookie;
use log::error;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt;
use yaak_crypto::manager::EncryptionManager;
@@ -59,7 +59,55 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
}
InternalEventPayload::PromptFormRequest(_) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
Ok(call_frontend(&window, event).await)
if event.reply_id.is_some() {
// Follow-up update from plugin runtime with resolved inputs — forward to frontend
window.emit_to(window.label(), "plugin_event", event.clone())?;
Ok(None)
} else {
// Initial request — set up bidirectional communication
window.emit_to(window.label(), "plugin_event", event.clone()).unwrap();
let event_id = event.id.clone();
let plugin_handle = plugin_handle.clone();
let plugin_context = plugin_context.clone();
let window = window.clone();
// Spawn async task to handle bidirectional form communication
tauri::async_runtime::spawn(async move {
let (tx, mut rx) = tokio::sync::mpsc::channel::<InternalEvent>(128);
// Listen for replies from the frontend
let listener_id = window.listen(event_id, move |ev: tauri::Event| {
let resp: InternalEvent = serde_json::from_str(ev.payload()).unwrap();
let _ = tx.try_send(resp);
});
// Forward each reply to the plugin runtime
while let Some(resp) = rx.recv().await {
let is_done = matches!(
&resp.payload,
InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)
);
let event_to_send = plugin_handle.build_event_to_send(
&plugin_context,
&resp.payload,
Some(resp.reply_id.unwrap_or_default()),
);
if let Err(e) = plugin_handle.send(&event_to_send).await {
log::warn!("Failed to forward form response to plugin: {:?}", e);
}
if is_done {
break;
}
}
window.unlisten(listener_id);
});
Ok(None)
}
}
InternalEventPayload::FindHttpResponsesRequest(req) => {
let http_responses = app_handle

View File

@@ -38,6 +38,9 @@ pub async fn render_grpc_request<T: TemplateCallback>(
let mut metadata = Vec::new();
for p in r.metadata.clone() {
if !p.enabled {
continue;
}
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, &opt).await?,
@@ -119,6 +122,7 @@ pub async fn render_http_request<T: TemplateCallback>(
let mut body = BTreeMap::new();
for (k, v) in r.body.clone() {
let v = if k == "form" { strip_disabled_form_entries(v) } else { v };
body.insert(k, render_json_value_raw(v, vars, cb, &opt).await?);
}
@@ -161,3 +165,71 @@ pub async fn render_http_request<T: TemplateCallback>(
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() })
}
/// Strip disabled entries from a JSON array of form objects.
fn strip_disabled_form_entries(v: Value) -> Value {
match v {
Value::Array(items) => Value::Array(
items
.into_iter()
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.collect(),
),
v => v,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_strip_disabled_form_entries() {
let input = json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": false, "name": "disabled", "value": "gone"},
{"enabled": true, "name": "baz", "value": "qux"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": true, "name": "baz", "value": "qux"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_all_disabled() {
let input = json!([
{"enabled": false, "name": "a", "value": "b"},
{"enabled": false, "name": "c", "value": "d"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(result, json!([]));
}
#[test]
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
let input = json!([
{"name": "no_enabled_field", "value": "kept"},
{"enabled": false, "name": "disabled", "value": "gone"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"name": "no_enabled_field", "value": "kept"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_non_array_passthrough() {
let input = json!("just a string");
let result = strip_disabled_form_entries(input.clone());
assert_eq!(result, input);
}
}

View File

@@ -1,9 +1,6 @@
{
"build": {
"features": [
"updater",
"license"
]
"features": ["updater", "license"]
},
"app": {
"security": {
@@ -11,12 +8,8 @@
"default",
{
"identifier": "release",
"windows": [
"*"
],
"permissions": [
"yaak-license:default"
]
"windows": ["*"],
"permissions": ["yaak-license:default"]
}
]
}
@@ -39,14 +32,7 @@
"createUpdaterArtifacts": true,
"longDescription": "A cross-platform desktop app for interacting with REST, GraphQL, and gRPC",
"shortDescription": "Play with APIs, intuitively",
"targets": [
"app",
"appimage",
"deb",
"dmg",
"nsis",
"rpm"
],
"targets": ["app", "appimage", "deb", "dmg", "nsis", "rpm"],
"macOS": {
"minimumSystemVersion": "13.0",
"exceptionDomain": "",
@@ -58,10 +44,16 @@
},
"linux": {
"deb": {
"desktopTemplate": "./template.desktop"
"desktopTemplate": "./template.desktop",
"files": {
"usr/share/metainfo/app.yaak.Yaak.metainfo.xml": "../../flatpak/app.yaak.Yaak.metainfo.xml"
}
},
"rpm": {
"desktopTemplate": "./template.desktop"
"desktopTemplate": "./template.desktop",
"files": {
"usr/share/metainfo/app.yaak.Yaak.metainfo.xml": "../../flatpak/app.yaak.Yaak.metainfo.xml"
}
}
}
}

View File

@@ -6,7 +6,7 @@ publish = false
[dependencies]
chrono = { workspace = true, features = ["serde"] }
git2 = { version = "0.20.0", features = ["vendored-libgit2", "vendored-openssl"] }
git2 = { version = "0.20.4", features = ["vendored-libgit2", "vendored-openssl"] }
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@@ -32,22 +32,30 @@ export interface GitCallbacks {
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
export function useGit(dir: string, callbacks: GitCallbacks) {
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
const fetchAll = useQuery<void, string>({
queryKey: ['git', 'fetch_all', dir, refreshKey],
queryFn: () => invoke('cmd_git_fetch_all', { dir }),
refetchInterval: 10 * 60_000,
});
return [
{
remotes: useQuery<GitRemote[], string>({
queryKey: ['git', 'remotes', dir],
queryKey: ['git', 'remotes', dir, refreshKey],
queryFn: () => getRemotes(dir),
placeholderData: (prev) => prev,
}),
log: useQuery<GitCommit[], string>({
queryKey: ['git', 'log', dir],
queryKey: ['git', 'log', dir, refreshKey],
queryFn: () => invoke('cmd_git_log', { dir }),
placeholderData: (prev) => prev,
}),
status: useQuery<GitStatusSummary, string>({
refetchOnMount: true,
queryKey: ['git', 'status', dir],
queryKey: ['git', 'status', dir, refreshKey, fetchAll.dataUpdatedAt],
queryFn: () => invoke('cmd_git_status', { dir }),
placeholderData: (prev) => prev,
}),
},
mutations,
@@ -152,10 +160,7 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
},
onSuccess,
}),
fetchAll: createFastMutation<void, string, void>({
mutationKey: ['git', 'fetch_all', dir],
mutationFn: () => invoke('cmd_git_fetch_all', { dir }),
}),
push: createFastMutation<PushResult, string, void>({
mutationKey: ['git', 'push', dir],
mutationFn: push,

View File

@@ -8,7 +8,7 @@ publish = false
async-compression = { version = "0.4", features = ["tokio", "gzip", "deflate", "brotli", "zstd"] }
async-trait = "0.1"
brotli = "7"
bytes = "1.5.0"
bytes = "1.11.1"
cookie = "0.18.1"
flate2 = "1"
futures-util = "0.3"

View File

@@ -66,7 +66,9 @@ export type DeleteModelRequest = { model: string, id: string, };
export type DeleteModelResponse = { model: AnyModel, };
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown";
export type DialogSize = "sm" | "md" | "lg" | "full" | "dynamic";
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown" | "c" | "clojure" | "csharp" | "go" | "http" | "java" | "kotlin" | "objective_c" | "ocaml" | "php" | "powershell" | "python" | "r" | "ruby" | "shell" | "swift";
export type EmptyPayload = {};
@@ -172,7 +174,11 @@ hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array<GenericCompletionOption>,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
@@ -476,9 +482,9 @@ label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PluginContext = { id: string, label: string | null, workspaceId: string | null, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, size?: DialogSize, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**

View File

@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };

View File

@@ -587,6 +587,19 @@ pub struct PromptFormRequest {
pub confirm_text: Option<String>,
#[ts(optional)]
pub cancel_text: Option<String>,
#[ts(optional)]
pub size: Option<DialogSize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_events.ts")]
pub enum DialogSize {
Sm,
Md,
Lg,
Full,
Dynamic,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
@@ -594,6 +607,8 @@ pub struct PromptFormRequest {
#[ts(export, export_to = "gen_events.ts")]
pub struct PromptFormResponse {
pub values: Option<HashMap<String, JsonPrimitive>>,
#[ts(optional)]
pub done: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
@@ -936,6 +951,22 @@ pub enum EditorLanguage {
Xml,
Graphql,
Markdown,
C,
Clojure,
Csharp,
Go,
Http,
Java,
Kotlin,
ObjectiveC,
Ocaml,
Php,
Powershell,
Python,
R,
Ruby,
Shell,
Swift,
}
impl Default for EditorLanguage {
@@ -966,6 +997,10 @@ pub struct FormInputEditor {
#[ts(optional)]
pub read_only: Option<bool>,
/// Fixed number of visible rows
#[ts(optional)]
pub rows: Option<i32>,
#[ts(optional)]
pub completion_options: Option<Vec<GenericCompletionOption>>,
}

View File

@@ -16,6 +16,9 @@ pub async fn render_websocket_request<T: TemplateCallback>(
let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() {
if !p.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: p.enabled,
name: parse_and_render(&p.name, vars, cb, opt).await?,
@@ -26,6 +29,9 @@ pub async fn render_websocket_request<T: TemplateCallback>(
let mut headers = Vec::new();
for p in r.headers.clone() {
if !p.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(&p.name, vars, cb, opt).await?,

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>app.yaak.Yaak</id>
<name>Yaak</name>
<summary>An offline, Git friendly API Client</summary>
<developer id="app.yaak">
<name>Yaak</name>
</developer>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<url type="homepage">https://yaak.app</url>
<url type="bugtracker">https://yaak.app/feedback</url>
<url type="contact">https://yaak.app/feedback</url>
<url type="vcs-browser">https://github.com/mountain-loop/yaak</url>
<description>
<p>
A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket,
and gRPC — built with Tauri, Rust, and React.
</p>
<p>Features include:</p>
<ul>
<li>REST, GraphQL, SSE, WebSocket, and gRPC support</li>
<li>Local-only data, secrets encryption, and zero telemetry</li>
<li>Git-friendly plain-text project storage</li>
<li>Environment variables and template functions</li>
<li>Request chaining and dynamic values</li>
<li>OAuth 2.0, Bearer, Basic, API Key, AWS, JWT, and NTLM authentication</li>
<li>Import from cURL, Postman, Insomnia, and OpenAPI</li>
<li>Extensible plugin system</li>
</ul>
</description>
<launchable type="desktop-id">app.yaak.Yaak.desktop</launchable>
<branding>
<color type="primary" scheme_preference="light">#8b32ff</color>
<color type="primary" scheme_preference="dark">#c293ff</color>
</branding>
<content_rating type="oars-1.1" />
<screenshots>
<screenshot type="default">
<caption>Crafting an API request</caption>
<image>https://assets.yaak.app/uploads/screenshot-BLG1w_2310x1326.png</image>
</screenshot>
</screenshots>
<releases>
<release version="2026.1.2" date="2026-02-10" />
</releases>
</component>

61
flatpak/app.yaak.Yaak.yml Normal file
View File

@@ -0,0 +1,61 @@
id: app.yaak.Yaak
runtime: org.gnome.Platform
runtime-version: "49"
sdk: org.gnome.Sdk
command: yaak-app
rename-desktop-file: yaak.desktop
rename-icon: yaak-app
finish-args:
- --socket=wayland
- --socket=fallback-x11
- --share=ipc
- --device=dri
- --share=network
- --socket=pulseaudio # Preview audio responses
- --socket=ssh-auth # Git SSH remotes
- --socket=gpg-agent # Git commit signing
- --talk-name=org.freedesktop.secrets # Keyring for encryption
- --filesystem=home # Git repos, ~/.gitconfig, ~/.ssh, etc
modules:
- name: git
cleanup:
- /share
make-args:
- NO_PERL=1
- NO_TCLTK=1
make-install-args:
- INSTALL_SYMLINKS=1
- NO_PERL=1
- NO_TCLTK=1
sources:
- type: archive
url: https://www.kernel.org/pub/software/scm/git/git-2.48.1.tar.gz
sha256: 51b4d03b1e311ba673591210f94f24a4c5781453e1eb188822e3d9cdc04c2212
- name: yaak
buildsystem: simple
build-commands:
- ar -x yaak.deb
- tar -xf data.tar.gz
- mv usr/bin/* /app/bin
- mv usr/lib/* /app/lib
- mv usr/share/* /app/share
- install -Dm644 LICENSE /app/share/licenses/app.yaak.Yaak/LICENSE
sources:
- type: file
dest-filename: yaak.deb
url: https://github.com/mountain-loop/yaak/releases/download/v2026.1.2/yaak_2026.1.2_amd64.deb
sha256: "c4236b5bcf391e579dc79b71c3b5c58f6f9bfc6c175fc70426d0ca85799beba5"
only-arches:
- x86_64
- type: file
dest-filename: yaak.deb
url: https://github.com/mountain-loop/yaak/releases/download/v2026.1.2/yaak_2026.1.2_arm64.deb
sha256: "9ba9b7c9df56ffb9b801e40cb38685f1650cf7e2f9e85dad0ae3329f8e01ff6d"
only-arches:
- aarch64
- type: file
path: ../LICENSE

82
flatpak/update-manifest.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# Update the Flatpak manifest with URLs and SHA256 hashes for a given release.
#
# Usage:
# ./flatpak/update-manifest.sh v2026.2.0
#
# This script:
# 1. Downloads the x86_64 and aarch64 .deb files from the GitHub release
# 2. Computes their SHA256 checksums
# 3. Updates the manifest YAML with the correct URLs and hashes
# 4. Updates the metainfo.xml with a new <release> entry
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MANIFEST="$SCRIPT_DIR/app.yaak.Yaak.yml"
METAINFO="$SCRIPT_DIR/app.yaak.Yaak.metainfo.xml"
if [ $# -lt 1 ]; then
echo "Usage: $0 <version-tag>"
echo "Example: $0 v2026.2.0"
exit 1
fi
VERSION_TAG="$1"
VERSION="${VERSION_TAG#v}"
# Only allow stable releases (skip beta, alpha, rc, etc.)
if [[ "$VERSION" == *-* ]]; then
echo "Skipping pre-release version '$VERSION_TAG' (only stable releases are published to Flathub)"
exit 0
fi
REPO="mountain-loop/yaak"
BASE_URL="https://github.com/$REPO/releases/download/$VERSION_TAG"
DEB_AMD64="yaak_${VERSION}_amd64.deb"
DEB_ARM64="yaak_${VERSION}_arm64.deb"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo "Downloading $DEB_AMD64..."
curl -fSL "$BASE_URL/$DEB_AMD64" -o "$TMPDIR/$DEB_AMD64"
SHA_AMD64=$(sha256sum "$TMPDIR/$DEB_AMD64" | cut -d' ' -f1)
echo " SHA256: $SHA_AMD64"
echo "Downloading $DEB_ARM64..."
curl -fSL "$BASE_URL/$DEB_ARM64" -o "$TMPDIR/$DEB_ARM64"
SHA_ARM64=$(sha256sum "$TMPDIR/$DEB_ARM64" | cut -d' ' -f1)
echo " SHA256: $SHA_ARM64"
echo ""
echo "Updating manifest: $MANIFEST"
# Update URLs by matching the arch-specific deb filename
sed -i "s|url: .*amd64\.deb|url: $BASE_URL/$DEB_AMD64|" "$MANIFEST"
sed -i "s|url: .*arm64\.deb|url: $BASE_URL/$DEB_ARM64|" "$MANIFEST"
# Update SHA256 hashes by finding the current ones and replacing
OLD_SHA_AMD64=$(grep -A2 "amd64\.deb" "$MANIFEST" | grep sha256 | sed 's/.*"\(.*\)"/\1/')
OLD_SHA_ARM64=$(grep -A2 "arm64\.deb" "$MANIFEST" | grep sha256 | sed 's/.*"\(.*\)"/\1/')
sed -i "s|$OLD_SHA_AMD64|$SHA_AMD64|" "$MANIFEST"
sed -i "s|$OLD_SHA_ARM64|$SHA_ARM64|" "$MANIFEST"
echo " Manifest updated."
echo "Updating metainfo: $METAINFO"
TODAY=$(date +%Y-%m-%d)
# Insert new release entry after <releases>
sed -i "s| <releases>| <releases>\n <release version=\"$VERSION\" date=\"$TODAY\" />|" "$METAINFO"
echo " Metainfo updated."
echo ""
echo "Done! Review the changes:"
echo " $MANIFEST"
echo " $METAINFO"

302
package-lock.json generated
View File

@@ -12,7 +12,8 @@
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins-external/template-function-faker",
"plugins/template-function-faker",
"plugins-external/httpsnippet",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/action-send-folder",
@@ -62,6 +63,13 @@
"crates/yaak-ws",
"src-web"
],
"dependencies": {
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/legacy-modes": "^6.5.2"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@tauri-apps/cli": "^2.9.6",
@@ -736,6 +744,19 @@
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-go": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
"integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/go": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
@@ -753,6 +774,16 @@
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-java": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
"integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/java": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
@@ -793,6 +824,32 @@
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-php": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/php": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
@@ -836,6 +893,15 @@
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
"integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
@@ -1414,9 +1480,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.8",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.8.tgz",
"integrity": "sha512-0/g2lIOPzX8f3vzW1ggQgvG5mjtFBDBHFAzI5SFAi2DzSqS9luJwqg9T6O/gKYLi+inS7eNxBeIFkkghIPvrMA==",
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -1570,6 +1636,17 @@
"lezer-generator": "src/lezer-generator.cjs"
}
},
"node_modules/@lezer/go": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
@@ -1590,6 +1667,17 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/java": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
"integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
@@ -1631,6 +1719,28 @@
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/php": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz",
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.1.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
@@ -1675,12 +1785,12 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.25.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.7",
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -1688,14 +1798,15 @@
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"jose": "^6.1.1",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.0"
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
@@ -2021,6 +2132,19 @@
"node": ">=16.9"
}
},
"node_modules/@readme/httpsnippet": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@readme/httpsnippet/-/httpsnippet-11.0.0.tgz",
"integrity": "sha512-XSyaAsJkZfmMO9R4WDlVJARZgd4wlImftSkMkKclidniXA1h6DTya9iTqJenQo9mHQLh3u6kAC3CDRaIV+LbLw==",
"license": "MIT",
"dependencies": {
"qs": "^6.11.2",
"stringify-object": "^3.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@replit/codemirror-emacs": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.1.0.tgz",
@@ -3798,13 +3922,6 @@
"@types/react": "*"
}
},
"node_modules/@types/shell-quote": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz",
"integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -4036,6 +4153,10 @@
"resolved": "plugins/auth-oauth2",
"link": true
},
"node_modules/@yaak/faker": {
"resolved": "plugins/template-function-faker",
"link": true
},
"node_modules/@yaak/filter-jsonpath": {
"resolved": "plugins/filter-jsonpath",
"link": true
@@ -4044,6 +4165,10 @@
"resolved": "plugins/filter-xpath",
"link": true
},
"node_modules/@yaak/httpsnippet": {
"resolved": "plugins-external/httpsnippet",
"link": true
},
"node_modules/@yaak/importer-curl": {
"resolved": "plugins/importer-curl",
"link": true
@@ -6865,10 +6990,13 @@
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
@@ -7407,6 +7535,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-own-enumerable-property-symbols": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
"integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
"license": "ISC"
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -8135,6 +8269,15 @@
"node": ">= 0.4"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ip-bigint": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ip-bigint/-/ip-bigint-7.3.0.tgz",
@@ -8516,6 +8659,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-obj": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
"integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -13250,6 +13402,7 @@
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -13258,6 +13411,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/shlex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shlex/-/shlex-3.0.0.tgz",
"integrity": "sha512-jHPXQQk9d/QXCvJuLPYMOYWez3c43sORAgcIEoV7bFv5AJSJRAOyw5lQO12PMfd385qiLRCaDt7OtEzgrIGZUA==",
"license": "MIT"
},
"node_modules/should": {
"version": "13.2.3",
"resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz",
@@ -13776,6 +13935,29 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stringify-object": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
"integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
"license": "BSD-2-Clause",
"dependencies": {
"get-own-enumerable-property-symbols": "^3.0.0",
"is-obj": "^1.0.1",
"is-regexp": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/stringify-object/node_modules/is-regexp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
"integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
@@ -15775,13 +15957,41 @@
"undici-types": "~7.16.0"
}
},
"plugins-external/httpsnippet": {
"name": "@yaak/httpsnippet",
"version": "1.0.3",
"dependencies": {
"@readme/httpsnippet": "^11.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
},
"plugins-external/httpsnippet/node_modules/@types/node": {
"version": "22.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz",
"integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"plugins-external/httpsnippet/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"plugins-external/mcp-server": {
"name": "@yaak/mcp-server",
"version": "0.1.7",
"version": "0.2.1",
"dependencies": {
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.11.7",
"zod": "^3.25.76"
},
@@ -15852,6 +16062,18 @@
"name": "@yaak/auth-oauth2",
"version": "0.1.0"
},
"plugins/faker": {
"name": "@yaak/faker",
"version": "1.1.1",
"extraneous": true,
"dependencies": {
"@faker-js/faker": "^10.1.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3"
}
},
"plugins/filter-jsonpath": {
"name": "@yaak/filter-jsonpath",
"version": "0.1.0",
@@ -15874,10 +16096,7 @@
"name": "@yaak/importer-curl",
"version": "0.1.0",
"dependencies": {
"shell-quote": "^1.8.1"
},
"devDependencies": {
"@types/shell-quote": "^1.7.5"
"shlex": "^3.0.0"
}
},
"plugins/importer-insomnia": {
@@ -15932,6 +16151,33 @@
"name": "@yaak/template-function-encode",
"version": "0.1.0"
},
"plugins/template-function-faker": {
"name": "@yaak/faker",
"version": "1.1.1",
"dependencies": {
"@faker-js/faker": "^10.1.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3"
}
},
"plugins/template-function-faker/node_modules/@faker-js/faker": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.3.0.tgz",
"integrity": "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"plugins/template-function-fs": {
"name": "@yaak/template-function-fs",
"version": "0.1.0"

View File

@@ -11,7 +11,8 @@
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins-external/template-function-faker",
"plugins/template-function-faker",
"plugins-external/httpsnippet",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/action-send-folder",
@@ -104,5 +105,12 @@
"npm-run-all": "^4.1.5",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"dependencies": {
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/legacy-modes": "^6.5.2"
}
}

View File

@@ -66,7 +66,9 @@ export type DeleteModelRequest = { model: string, id: string, };
export type DeleteModelResponse = { model: AnyModel, };
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown";
export type DialogSize = "sm" | "md" | "lg" | "full" | "dynamic";
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown" | "c" | "clojure" | "csharp" | "go" | "http" | "java" | "kotlin" | "objective_c" | "ocaml" | "php" | "powershell" | "python" | "r" | "ruby" | "shell" | "swift";
export type EmptyPayload = {};
@@ -172,7 +174,11 @@ hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array<GenericCompletionOption>,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
@@ -476,9 +482,9 @@ label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PluginContext = { id: string, label: string | null, workspaceId: string | null, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, size?: DialogSize, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**

View File

@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };

View File

@@ -1,10 +1,12 @@
import type {
FindHttpResponsesRequest,
FindHttpResponsesResponse,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse,
JsonPrimitive,
ListCookieNamesResponse,
ListFoldersRequest,
ListFoldersResponse,
@@ -27,6 +29,39 @@ import type {
} from '../bindings/gen_events.ts';
import type { Folder, HttpRequest } from '../bindings/gen_models.ts';
import type { JsonValue } from '../bindings/serde_json/JsonValue';
import type { MaybePromise } from '../helpers';
export type CallPromptFormDynamicArgs = {
values: { [key in string]?: JsonPrimitive };
};
type AddDynamicMethod<T> = {
dynamic?: (
ctx: Context,
args: CallPromptFormDynamicArgs,
) => MaybePromise<Partial<T> | null | undefined>;
};
// biome-ignore lint/suspicious/noExplicitAny: distributive conditional type pattern
type AddDynamic<T> = T extends any
? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {
inputs: Array<AddDynamic<FormInput>>;
dynamic?: (
ctx: Context,
args: CallPromptFormDynamicArgs,
) => MaybePromise<
Partial<Omit<T, 'inputs'> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
>;
}
: T & AddDynamicMethod<T>
: never;
export type DynamicPromptFormArg = AddDynamic<FormInput>;
type DynamicPromptFormRequest = Omit<PromptFormRequest, 'inputs'> & {
inputs: DynamicPromptFormArg[];
};
export type WorkspaceHandle = Pick<WorkspaceInfo, 'id' | 'name'>;
@@ -39,7 +74,7 @@ export interface Context {
};
prompt: {
text(args: PromptTextRequest): Promise<PromptTextResponse['value']>;
form(args: PromptFormRequest): Promise<PromptFormResponse['values']>;
form(args: DynamicPromptFormRequest): Promise<PromptFormResponse['values']>;
};
store: {
set<T>(key: string, value: T): Promise<void>;

View File

@@ -2,21 +2,22 @@ import type { AuthenticationPlugin } from './AuthenticationPlugin';
import type { Context } from './Context';
import type { FilterPlugin } from './FilterPlugin';
import type { FolderActionPlugin } from './FolderActionPlugin';
import type { GrpcRequestActionPlugin } from './GrpcRequestActionPlugin';
import type { HttpRequestActionPlugin } from './HttpRequestActionPlugin';
import type { WebsocketRequestActionPlugin } from './WebsocketRequestActionPlugin';
import type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
import type { FolderActionPlugin } from './FolderActionPlugin';
import type { ImporterPlugin } from './ImporterPlugin';
import type { TemplateFunctionPlugin } from './TemplateFunctionPlugin';
import type { ThemePlugin } from './ThemePlugin';
import type { WebsocketRequestActionPlugin } from './WebsocketRequestActionPlugin';
import type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
export type { Context };
export type { DynamicTemplateFunctionArg } from './TemplateFunctionPlugin';
export type { DynamicAuthenticationArg } from './AuthenticationPlugin';
export type { CallPromptFormDynamicArgs, DynamicPromptFormArg } from './Context';
export type { DynamicTemplateFunctionArg } from './TemplateFunctionPlugin';
export type { TemplateFunctionPlugin };
export type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
export type { FolderActionPlugin } from './FolderActionPlugin';
export type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
/**
* The global structure of a Yaak plugin

View File

@@ -1,7 +1,12 @@
import console from 'node:console';
import { type Stats, statSync, watch } from 'node:fs';
import path from 'node:path';
import type { Context, PluginDefinition } from '@yaakapp/api';
import type {
CallPromptFormDynamicArgs,
Context,
DynamicPromptFormArg,
PluginDefinition,
} from '@yaakapp/api';
import {
applyFormInputDefaults,
validateTemplateFunctionArgs,
@@ -12,6 +17,7 @@ import type {
DeleteModelResponse,
FindHttpResponsesResponse,
Folder,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
@@ -55,6 +61,7 @@ export class PluginInstance {
#mod: PluginDefinition;
#pluginToAppEvents: EventChannel;
#appToPluginEvents: EventChannel;
#pendingDynamicForms = new Map<string, DynamicPromptFormArg[]>();
constructor(workerData: PluginWorkerData, pluginEvents: EventChannel) {
this.#workerData = workerData;
@@ -106,6 +113,7 @@ export class PluginInstance {
async terminate() {
await this.#mod?.dispose?.();
this.#pendingDynamicForms.clear();
this.#unimportModule();
}
@@ -299,7 +307,7 @@ export class PluginInstance {
const replyPayload: InternalEventPayload = {
type: 'get_template_function_config_response',
pluginRefId: this.#workerData.pluginRefId,
function: { ...fn, args: resolvedArgs },
function: { ...fn, args: stripDynamicCallbacks(resolvedArgs) },
};
this.#sendPayload(context, replyPayload, replyId);
return;
@@ -326,7 +334,7 @@ export class PluginInstance {
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_config_response',
args: resolvedArgs,
args: stripDynamicCallbacks(resolvedArgs),
actions: resolvedActions,
pluginRefId: this.#workerData.pluginRefId,
};
@@ -664,10 +672,66 @@ export class PluginInstance {
return reply.value;
},
form: async (args) => {
const reply: PromptFormResponse = await this.#sendForReply(context, {
type: 'prompt_form_request',
...args,
// Resolve dynamic callbacks on initial inputs using default values
const defaults = applyFormInputDefaults(args.inputs, {});
const callArgs: CallPromptFormDynamicArgs = { values: defaults };
const resolvedInputs = await applyDynamicFormInput(
this.#newCtx(context),
args.inputs,
callArgs,
);
const strippedInputs = stripDynamicCallbacks(resolvedInputs);
// Build the event manually so we can get the event ID for keying
const eventToSend = this.#buildEventToSend(
context,
{ type: 'prompt_form_request', ...args, inputs: strippedInputs },
null,
);
// Store original inputs (with dynamic callbacks) for later resolution
this.#pendingDynamicForms.set(eventToSend.id, args.inputs);
const reply = await new Promise<PromptFormResponse>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId !== eventToSend.id) return;
if (event.payload.type === 'prompt_form_response') {
const { done, values } = event.payload as PromptFormResponse;
if (done) {
// Final response — resolve the promise and clean up
this.#appToPluginEvents.unlisten(cb);
this.#pendingDynamicForms.delete(eventToSend.id);
resolve({ values } as PromptFormResponse);
} else {
// Intermediate value change — resolve dynamic inputs and send back
// Skip empty values (fired on initial mount before user interaction)
const storedInputs = this.#pendingDynamicForms.get(eventToSend.id);
if (storedInputs && values && Object.keys(values).length > 0) {
const ctx = this.#newCtx(context);
const callArgs: CallPromptFormDynamicArgs = { values };
applyDynamicFormInput(ctx, storedInputs, callArgs)
.then((resolvedInputs) => {
const stripped = stripDynamicCallbacks(resolvedInputs);
this.#sendPayload(
context,
{ type: 'prompt_form_request', ...args, inputs: stripped },
eventToSend.id,
);
})
.catch((err) => {
console.error('Failed to resolve dynamic form inputs', err);
});
}
}
}
};
this.#appToPluginEvents.listen(cb);
// Send the initial event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
});
return reply.values;
},
},
@@ -906,6 +970,17 @@ export class PluginInstance {
}
}
function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {
return inputs.map((input) => {
// biome-ignore lint/suspicious/noExplicitAny: stripping dynamic from union type
const { dynamic, ...rest } = input as any;
if ('inputs' in rest && Array.isArray(rest.inputs)) {
rest.inputs = stripDynamicCallbacks(rest.inputs);
}
return rest as FormInput;
});
}
function genId(len = 5): string {
const alphabet = '01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let id = '';

View File

@@ -1,9 +1,21 @@
import type { Context, DynamicAuthenticationArg, DynamicTemplateFunctionArg } from '@yaakapp/api';
import type {
CallPromptFormDynamicArgs,
Context,
DynamicAuthenticationArg,
DynamicPromptFormArg,
DynamicTemplateFunctionArg,
} from '@yaakapp/api';
import type {
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
} from '@yaakapp-internal/plugins';
type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;
type AnyCallArgs =
| CallTemplateFunctionArgs
| CallHttpAuthenticationActionArgs
| CallPromptFormDynamicArgs;
export async function applyDynamicFormInput(
ctx: Context,
args: DynamicTemplateFunctionArg[],
@@ -18,30 +30,40 @@ export async function applyDynamicFormInput(
export async function applyDynamicFormInput(
ctx: Context,
args: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[],
callArgs: CallTemplateFunctionArgs | CallHttpAuthenticationActionArgs,
): Promise<(DynamicTemplateFunctionArg | DynamicAuthenticationArg)[]> {
const resolvedArgs: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[] = [];
args: DynamicPromptFormArg[],
callArgs: CallPromptFormDynamicArgs,
): Promise<DynamicPromptFormArg[]>;
export async function applyDynamicFormInput(
ctx: Context,
args: AnyDynamicArg[],
callArgs: AnyCallArgs,
): Promise<AnyDynamicArg[]> {
const resolvedArgs: AnyDynamicArg[] = [];
for (const { dynamic, ...arg } of args) {
const dynamicResult =
typeof dynamic === 'function'
? await dynamic(
ctx,
callArgs as CallTemplateFunctionArgs & CallHttpAuthenticationActionArgs,
callArgs as CallTemplateFunctionArgs &
CallHttpAuthenticationActionArgs &
CallPromptFormDynamicArgs,
)
: undefined;
const newArg = {
...arg,
...dynamicResult,
} as DynamicTemplateFunctionArg | DynamicAuthenticationArg;
} as AnyDynamicArg;
if ('inputs' in newArg && Array.isArray(newArg.inputs)) {
try {
newArg.inputs = await applyDynamicFormInput(
ctx,
newArg.inputs as DynamicTemplateFunctionArg[],
callArgs as CallTemplateFunctionArgs & CallHttpAuthenticationActionArgs,
callArgs as CallTemplateFunctionArgs &
CallHttpAuthenticationActionArgs &
CallPromptFormDynamicArgs,
);
} catch (e) {
console.error('Failed to apply dynamic form input', e);

View File

@@ -1,9 +0,0 @@
import { describe, expect, it } from 'vitest';
describe('formatDatetime', () => {
it('returns formatted current date', async () => {
// Ensure the plugin imports properly
const faker = await import('../src/index');
expect(faker.plugin.templateFunctions?.length).toBe(226);
});
});

View File

@@ -0,0 +1,45 @@
# Yaak HTTP Snippet Plugin
Generate code snippets for HTTP requests in various languages and frameworks,
powered by [@readme/httpsnippet](https://github.com/readmeio/httpsnippet).
![Httpsnippet plugin](https://assets.yaak.app/uploads/httpsnippet-guiaX_1786x1420.png)
## How It Works
Right-click any HTTP request (or use the `...` menu) and select **Generate Code Snippet**.
A dialog lets you pick a language and library, with a live preview of the generated code.
Click **Copy to Clipboard** to copy the snippet. Your language and library selections are
remembered for next time.
## Supported Languages
Each language supports one or more libraries:
| Language | Libraries |
|---|---|
| C | libcurl |
| Clojure | clj-http |
| C# | HttpClient, RestSharp |
| Go | Native |
| HTTP | HTTP/1.1 |
| Java | AsyncHttp, NetHttp, OkHttp, Unirest |
| JavaScript | Axios, fetch, jQuery, XHR |
| Kotlin | OkHttp |
| Node.js | Axios, fetch, HTTP, Request, Unirest |
| Objective-C | NSURLSession |
| OCaml | CoHTTP |
| PHP | cURL, Guzzle, HTTP v1, HTTP v2 |
| PowerShell | Invoke-WebRequest, RestMethod |
| Python | http.client, Requests |
| R | httr |
| Ruby | Native |
| Shell | cURL, HTTPie, Wget |
| Swift | URLSession |
## Features
- Renders template variables before generating snippets, so the output reflects real values
- Supports all body types: JSON, form-urlencoded, multipart, GraphQL, and raw text
- Includes authentication headers (Basic, Bearer, and API Key)
- Includes query parameters and custom headers

View File

@@ -0,0 +1,24 @@
{
"name": "@yaak/httpsnippet",
"private": true,
"version": "1.0.3",
"displayName": "HTTP Snippet",
"description": "Generate code snippets for HTTP requests in various languages and frameworks",
"minYaakVersion": "2026.2.0-beta.10",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins-external/httpsnippet"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
},
"dependencies": {
"@readme/httpsnippet": "^11.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,314 @@
import { availableTargets, type HarRequest, HTTPSnippet } from '@readme/httpsnippet';
import type { EditorLanguage, HttpRequest, PluginDefinition } from '@yaakapp/api';
// Get all available targets and build select options
const targets = availableTargets();
// Targets to exclude from the language list
const excludedTargets = new Set(['json']);
// Build language (target) options
const languageOptions = targets
.filter((target) => !excludedTargets.has(target.key))
.map((target) => ({
label: target.title,
value: target.key,
}));
// Preferred clients per target (shown first in the list)
const preferredClients: Record<string, string> = {
javascript: 'fetch',
node: 'fetch',
};
// Get client options for a given target key
function getClientOptions(targetKey: string) {
const target = targets.find((t) => t.key === targetKey);
if (!target) return [];
const preferred = preferredClients[targetKey];
return target.clients
.map((client) => ({
label: client.title,
value: client.key,
}))
.sort((a, b) => {
if (a.value === preferred) return -1;
if (b.value === preferred) return 1;
return 0;
});
}
// Get default client for a target
function getDefaultClient(targetKey: string): string {
const options = getClientOptions(targetKey);
return options[0]?.value ?? '';
}
// Defaults
const defaultTarget = 'javascript';
// Map httpsnippet target key to editor language for syntax highlighting
const editorLanguageMap: Record<string, EditorLanguage> = {
c: 'c',
clojure: 'clojure',
csharp: 'csharp',
go: 'go',
http: 'http',
java: 'java',
javascript: 'javascript',
kotlin: 'kotlin',
node: 'javascript',
objc: 'objective_c',
ocaml: 'ocaml',
php: 'php',
powershell: 'powershell',
python: 'python',
r: 'r',
ruby: 'ruby',
shell: 'shell',
swift: 'swift',
};
function getEditorLanguage(targetKey: string): EditorLanguage {
return editorLanguageMap[targetKey] ?? 'text';
}
// Convert Yaak HttpRequest to HAR format
function toHarRequest(request: Partial<HttpRequest>) {
// Build URL with query parameters
let finalUrl = request.url || '';
const urlParams = (request.urlParameters ?? []).filter((p) => p.enabled !== false && !!p.name);
if (urlParams.length > 0) {
const [base, hash] = finalUrl.split('#');
const separator = base?.includes('?') ? '&' : '?';
const queryString = urlParams
.map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value)}`)
.join('&');
finalUrl = base + separator + queryString + (hash ? `#${hash}` : '');
}
// Build headers array
const headers: Array<{ name: string; value: string }> = (request.headers ?? [])
.filter((h) => h.enabled !== false && !!h.name)
.map((h) => ({ name: h.name, value: h.value }));
// Handle authentication
if (request.authentication?.disabled !== true) {
if (request.authenticationType === 'basic') {
const credentials = btoa(
`${request.authentication?.username ?? ''}:${request.authentication?.password ?? ''}`,
);
headers.push({ name: 'Authorization', value: `Basic ${credentials}` });
} else if (request.authenticationType === 'bearer') {
const prefix = request.authentication?.prefix ?? 'Bearer';
const token = request.authentication?.token ?? '';
headers.push({ name: 'Authorization', value: `${prefix} ${token}`.trim() });
} else if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'header') {
headers.push({
name: request.authentication?.key ?? 'X-Api-Key',
value: request.authentication?.value ?? '',
});
} else if (request.authentication?.location === 'query') {
const sep = finalUrl.includes('?') ? '&' : '?';
finalUrl = [
finalUrl,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
}
}
}
// Build HAR request object
const har: Record<string, unknown> = {
method: request.method || 'GET',
url: finalUrl,
headers,
};
// Handle request body
const bodyType = request.bodyType ?? 'none';
if (bodyType !== 'none' && request.body) {
if (bodyType === 'application/x-www-form-urlencoded' && Array.isArray(request.body.form)) {
const params = request.body.form
.filter((p: { enabled?: boolean; name?: string }) => p.enabled !== false && !!p.name)
.map((p: { name: string; value: string }) => ({ name: p.name, value: p.value }));
har.postData = {
mimeType: 'application/x-www-form-urlencoded',
params,
};
} else if (bodyType === 'multipart/form-data' && Array.isArray(request.body.form)) {
const params = request.body.form
.filter((p: { enabled?: boolean; name?: string }) => p.enabled !== false && !!p.name)
.map((p: { name: string; value: string; file?: string; contentType?: string }) => {
const param: Record<string, string> = { name: p.name, value: p.value || '' };
if (p.file) param.fileName = p.file;
if (p.contentType) param.contentType = p.contentType;
return param;
});
har.postData = {
mimeType: 'multipart/form-data',
params,
};
} else if (bodyType === 'graphql' && typeof request.body.query === 'string') {
const body = {
query: request.body.query || '',
variables: maybeParseJSON(request.body.variables, undefined),
};
har.postData = {
mimeType: 'application/json',
text: JSON.stringify(body),
};
} else if (typeof request.body.text === 'string') {
har.postData = {
mimeType: bodyType,
text: request.body.text,
};
}
}
return har;
}
function maybeParseJSON<T>(v: unknown, fallback: T): T | unknown {
if (typeof v !== 'string') return fallback;
try {
return JSON.parse(v);
} catch {
return fallback;
}
}
export const plugin: PluginDefinition = {
httpRequestActions: [
{
label: 'Generate Code Snippet',
icon: 'copy',
async onSelect(ctx, args) {
// Render the request with variables resolved
const renderedRequest = await ctx.httpRequest.render({
httpRequest: args.httpRequest,
purpose: 'send',
});
// Convert to HAR format
const harRequest = toHarRequest(renderedRequest) as HarRequest;
// Get previously selected language or use defaults
const storedTarget = await ctx.store.get<string>('selectedTarget');
const initialTarget = storedTarget || defaultTarget;
const storedClient = await ctx.store.get<string>(`selectedClient:${initialTarget}`);
const initialClient = storedClient || getDefaultClient(initialTarget);
// Create snippet generator
const snippet = new HTTPSnippet(harRequest);
const generateSnippet = (target: string, client: string): string => {
const result = snippet.convert(target as any, client);
return (Array.isArray(result) ? result.join('\n') : result || '').replace(/\r\n/g, '\n');
};
// Generate initial code preview
let initialCode = '';
try {
initialCode = generateSnippet(initialTarget, initialClient);
} catch {
initialCode = '// Error generating snippet';
}
// Show dialog with language/library selectors and code preview
const result = await ctx.prompt.form({
id: 'httpsnippet',
title: 'Generate Code Snippet',
confirmText: 'Copy to Clipboard',
cancelText: 'Cancel',
size: 'md',
inputs: [
{
type: 'h_stack',
inputs: [
{
type: 'select',
name: 'target',
label: 'Language',
defaultValue: initialTarget,
options: languageOptions,
},
{
type: 'select',
name: `client-${initialTarget}`,
label: 'Library',
defaultValue: initialClient,
options: getClientOptions(initialTarget),
dynamic(_ctx, { values }) {
const targetKey = String(values.target || defaultTarget);
const options = getClientOptions(targetKey);
return {
name: `client-${targetKey}`,
options,
defaultValue: options[0]?.value ?? '',
};
},
},
],
},
{
type: 'editor',
name: 'code',
label: 'Preview',
language: getEditorLanguage(initialTarget),
defaultValue: initialCode,
readOnly: true,
rows: 15,
dynamic(_ctx, { values }) {
const targetKey = String(values.target || defaultTarget);
const clientKey = String(
values[`client-${targetKey}`] || getDefaultClient(targetKey),
);
let code: string;
try {
code = generateSnippet(targetKey, clientKey);
} catch {
code = '// Error generating snippet';
}
return {
defaultValue: code,
language: getEditorLanguage(targetKey),
};
},
},
],
});
if (result) {
// Store the selected language and library for next time
const selectedTarget = String(result.target || initialTarget);
const selectedClient = String(
result[`client-${selectedTarget}`] || getDefaultClient(selectedTarget),
);
await ctx.store.set('selectedTarget', selectedTarget);
await ctx.store.set(`selectedClient:${selectedTarget}`, selectedClient);
// Generate snippet for the selected language
try {
const codeText = generateSnippet(selectedTarget, selectedClient);
await ctx.clipboard.copyText(codeText);
await ctx.toast.show({
message: 'Code snippet copied to clipboard',
icon: 'copy',
color: 'success',
});
} catch (err) {
await ctx.toast.show({
message: `Failed to generate snippet: ${err}`,
icon: 'alert_triangle',
color: 'danger',
});
}
}
},
},
],
};

View File

@@ -1,10 +1,10 @@
{
"name": "@yaak/mcp-server",
"private": true,
"version": "0.1.7",
"version": "0.2.1",
"displayName": "MCP Server",
"description": "Expose Yaak functionality via Model Context Protocol",
"minYaakVersion": "2025.10.0-beta.6",
"minYaakVersion": "2026.1.0",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
@@ -17,7 +17,7 @@
"dependencies": {
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.11.7",
"zod": "^3.25.76"
},

View File

@@ -10,7 +10,7 @@ export const plugin: PluginDefinition = {
async onSelect(ctx, args) {
const rendered_request = await ctx.httpRequest.render({
httpRequest: args.httpRequest,
purpose: 'preview',
purpose: 'send',
});
const data = await convertToCurl(rendered_request);
await ctx.clipboard.copyText(data);

View File

@@ -10,9 +10,6 @@
"test": "vitest --run tests"
},
"dependencies": {
"shell-quote": "^1.8.1"
},
"devDependencies": {
"@types/shell-quote": "^1.7.5"
"shlex": "^3.0.0"
}
}

View File

@@ -7,8 +7,7 @@ import type {
PluginDefinition,
Workspace,
} from '@yaakapp/api';
import type { ControlOperator, ParseEntry } from 'shell-quote';
import { parse } from 'shell-quote';
import { split } from 'shlex';
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
@@ -56,31 +55,89 @@ export const plugin: PluginDefinition = {
};
/**
* Decodes escape sequences in shell $'...' strings
* Handles Unicode escape sequences (\uXXXX) and common escape codes
* Splits raw input into individual shell command strings.
* Handles line continuations, semicolons, and newline-separated curl commands.
*/
function decodeShellString(str: string): string {
return str
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
}
function splitCommands(rawData: string): string[] {
// Join line continuations (backslash-newline, and backslash-CRLF for Windows)
const joined = rawData.replace(/\\\r?\n/g, ' ');
/**
* Checks if a string might contain escape sequences that need decoding
* If so, decodes them; otherwise returns the string as-is
*/
function maybeDecodeEscapeSequences(str: string): string {
// Check if the string contains escape sequences that shell-quote might not handle
if (str.includes('\\u') || str.includes('\\x')) {
return decodeShellString(str);
// Count consecutive backslashes immediately before position i.
// An even count means the quote at i is NOT escaped; odd means it IS escaped.
function isEscaped(i: number): boolean {
let backslashes = 0;
let j = i - 1;
while (j >= 0 && joined[j] === '\\') {
backslashes++;
j--;
}
return backslashes % 2 !== 0;
}
return str;
// Split on semicolons and newlines to separate commands
const commands: string[] = [];
let current = '';
let inSingleQuote = false;
let inDoubleQuote = false;
let inDollarQuote = false;
for (let i = 0; i < joined.length; i++) {
const ch = joined[i]!;
const next = joined[i + 1];
// Track quoting state to avoid splitting inside quoted strings
if (!inDoubleQuote && !inDollarQuote && ch === "'" && !inSingleQuote) {
inSingleQuote = true;
current += ch;
continue;
}
if (inSingleQuote && ch === "'") {
inSingleQuote = false;
current += ch;
continue;
}
if (!inSingleQuote && !inDollarQuote && ch === '"' && !inDoubleQuote) {
inDoubleQuote = true;
current += ch;
continue;
}
if (inDoubleQuote && ch === '"' && !isEscaped(i)) {
inDoubleQuote = false;
current += ch;
continue;
}
if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && ch === '$' && next === "'") {
inDollarQuote = true;
current += ch + next;
i++; // Skip the opening quote
continue;
}
if (inDollarQuote && ch === "'" && !isEscaped(i)) {
inDollarQuote = false;
current += ch;
continue;
}
const inQuote = inSingleQuote || inDoubleQuote || inDollarQuote;
// Split on ;, newline, or CRLF when not inside quotes and not escaped
if (!inQuote && !isEscaped(i) && (ch === ';' || ch === '\n' || (ch === '\r' && next === '\n'))) {
if (ch === '\r') i++; // Skip the \n in \r\n
if (current.trim()) {
commands.push(current.trim());
}
current = '';
continue;
}
current += ch;
}
if (current.trim()) {
commands.push(current.trim());
}
return commands;
}
export function convertCurl(rawData: string) {
@@ -88,68 +145,17 @@ export function convertCurl(rawData: string) {
return null;
}
const commands: ParseEntry[][] = [];
const commands: string[][] = splitCommands(rawData).map((cmd) => {
const tokens = split(cmd);
// Replace non-escaped newlines with semicolons to make parsing easier
// NOTE: This is really slow in debug build but fast in release mode
const normalizedData = rawData.replace(/\ncurl/g, '; curl');
let currentCommand: ParseEntry[] = [];
const parsed = parse(normalizedData);
// Break up `-XPOST` into `-X POST`
const normalizedParseEntries = parsed.flatMap((entry) => {
if (
typeof entry === 'string' &&
entry.startsWith('-') &&
!entry.startsWith('--') &&
entry.length > 2
) {
return [entry.slice(0, 2), entry.slice(2)];
}
return entry;
});
for (const parseEntry of normalizedParseEntries) {
if (typeof parseEntry === 'string') {
if (parseEntry.startsWith('$')) {
// Handle $'...' strings from shell-quote - decode escape sequences
currentCommand.push(decodeShellString(parseEntry.slice(1)));
} else {
// Decode escape sequences that shell-quote might not handle
currentCommand.push(maybeDecodeEscapeSequences(parseEntry));
// Break up squished arguments like `-XPOST` into `-X POST`
return tokens.flatMap((token) => {
if (token.startsWith('-') && !token.startsWith('--') && token.length > 2) {
return [token.slice(0, 2), token.slice(2)];
}
continue;
}
if ('comment' in parseEntry) {
continue;
}
const { op } = parseEntry as { op: 'glob'; pattern: string } | { op: ControlOperator };
// `;` separates commands
if (op === ';') {
commands.push(currentCommand);
currentCommand = [];
continue;
}
if (op?.startsWith('$')) {
// Handle the case where literal like -H $'Header: \'Some Quoted Thing\''
const str = decodeShellString(op.slice(2, op.length - 1));
currentCommand.push(str);
continue;
}
if (op === 'glob') {
currentCommand.push((parseEntry as { op: 'glob'; pattern: string }).pattern);
}
}
commands.push(currentCommand);
return token;
});
});
const workspace: ExportResources['workspaces'][0] = {
model: 'workspace',
@@ -169,12 +175,12 @@ export function convertCurl(rawData: string) {
};
}
function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
function importCommand(parseEntries: string[], workspaceId: string) {
// ~~~~~~~~~~~~~~~~~~~~~ //
// Collect all the flags //
// ~~~~~~~~~~~~~~~~~~~~~ //
const flagsByName: FlagsByName = {};
const singletons: ParseEntry[] = [];
const singletons: string[] = [];
// Start at 1 so we can skip the ^curl part
for (let i = 1; i < parseEntries.length; i++) {

View File

@@ -112,9 +112,28 @@ describe('importer-curl', () => {
});
});
test('Imports with Windows CRLF line endings', () => {
expect(
convertCurl('curl \\\r\n -X POST \\\r\n https://yaak.app'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({ url: 'https://yaak.app', method: 'POST' }),
],
},
});
});
test('Throws on malformed quotes', () => {
expect(() =>
convertCurl('curl -X POST -F "a=aaa" -F b=bbb" https://yaak.app'),
).toThrow();
});
test('Imports form data', () => {
expect(
convertCurl('curl -X POST -F "a=aaa" -F b=bbb" -F f=@filepath https://yaak.app'),
convertCurl('curl -X POST -F "a=aaa" -F b=bbb -F f=@filepath https://yaak.app'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
@@ -476,6 +495,130 @@ describe('importer-curl', () => {
});
});
test('Imports JSON body with newlines in $quotes', () => {
expect(
convertCurl(
`curl 'https://yaak.app' -H 'Content-Type: application/json' --data-raw $'{\\n "foo": "bar",\\n "baz": "qux"\\n}' -X POST`,
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
headers: [{ name: 'Content-Type', value: 'application/json', enabled: true }],
bodyType: 'application/json',
body: { text: '{\n "foo": "bar",\n "baz": "qux"\n}' },
}),
],
},
});
});
test('Handles double-quoted string ending with even backslashes before semicolon', () => {
// "C:\\" has two backslashes which escape each other, so the closing " is real.
// The ; after should split into a second command.
expect(
convertCurl(
'curl -d "C:\\\\" https://yaak.app;curl https://example.com',
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
body: {
form: [{ name: 'C:\\', value: '', enabled: true }],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
},
],
}),
baseRequest({ url: 'https://example.com' }),
],
},
});
});
test('Handles $quoted string ending with a literal backslash before semicolon', () => {
// $'C:\\\\' has two backslashes which become one literal backslash.
// The closing ' must not be misinterpreted as escaped.
// The ; after should split into a second command.
expect(
convertCurl(
"curl -d $'C:\\\\' https://yaak.app;curl https://example.com",
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
body: {
form: [{ name: 'C:\\', value: '', enabled: true }],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
},
],
}),
baseRequest({ url: 'https://example.com' }),
],
},
});
});
test('Imports $quoted header with escaped single quotes', () => {
expect(
convertCurl(
`curl https://yaak.app -H $'X-Custom: it\\'s a test'`,
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
headers: [{ name: 'X-Custom', value: "it's a test", enabled: true }],
}),
],
},
});
});
test('Does not split on escaped semicolon outside quotes', () => {
// In shell, \; is a literal semicolon and should not split commands.
// This should be treated as a single curl command with the URL "https://yaak.app?a=1;b=2"
expect(
convertCurl('curl https://yaak.app?a=1\\;b=2'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
urlParameters: [
{ name: 'a', value: '1;b=2', enabled: true },
],
}),
],
},
});
});
test('Imports multipart form data with text-only fields from --data-raw', () => {
const curlCommand = `curl 'http://example.com/api' \
-H 'Content-Type: multipart/form-data; boundary=----FormBoundary123' \

View File

@@ -7,7 +7,7 @@
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins-external/faker"
"directory": "plugins/template-function-faker"
},
"scripts": {
"build": "yaakcli build",

View File

@@ -0,0 +1,233 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`template-function-faker > exports all expected template functions 1`] = `
[
"faker.airline.aircraftType",
"faker.airline.airline",
"faker.airline.airplane",
"faker.airline.airport",
"faker.airline.flightNumber",
"faker.airline.recordLocator",
"faker.airline.seat",
"faker.animal.bear",
"faker.animal.bird",
"faker.animal.cat",
"faker.animal.cetacean",
"faker.animal.cow",
"faker.animal.crocodilia",
"faker.animal.dog",
"faker.animal.fish",
"faker.animal.horse",
"faker.animal.insect",
"faker.animal.lion",
"faker.animal.petName",
"faker.animal.rabbit",
"faker.animal.rodent",
"faker.animal.snake",
"faker.animal.type",
"faker.color.cmyk",
"faker.color.colorByCSSColorSpace",
"faker.color.cssSupportedFunction",
"faker.color.cssSupportedSpace",
"faker.color.hsl",
"faker.color.human",
"faker.color.hwb",
"faker.color.lab",
"faker.color.lch",
"faker.color.rgb",
"faker.color.space",
"faker.commerce.department",
"faker.commerce.isbn",
"faker.commerce.price",
"faker.commerce.product",
"faker.commerce.productAdjective",
"faker.commerce.productDescription",
"faker.commerce.productMaterial",
"faker.commerce.productName",
"faker.commerce.upc",
"faker.company.buzzAdjective",
"faker.company.buzzNoun",
"faker.company.buzzPhrase",
"faker.company.buzzVerb",
"faker.company.catchPhrase",
"faker.company.catchPhraseAdjective",
"faker.company.catchPhraseDescriptor",
"faker.company.catchPhraseNoun",
"faker.company.name",
"faker.database.collation",
"faker.database.column",
"faker.database.engine",
"faker.database.mongodbObjectId",
"faker.database.type",
"faker.date.anytime",
"faker.date.between",
"faker.date.betweens",
"faker.date.birthdate",
"faker.date.future",
"faker.date.month",
"faker.date.past",
"faker.date.recent",
"faker.date.soon",
"faker.date.timeZone",
"faker.date.weekday",
"faker.finance.accountName",
"faker.finance.accountNumber",
"faker.finance.amount",
"faker.finance.bic",
"faker.finance.bitcoinAddress",
"faker.finance.creditCardCVV",
"faker.finance.creditCardIssuer",
"faker.finance.creditCardNumber",
"faker.finance.currency",
"faker.finance.currencyCode",
"faker.finance.currencyName",
"faker.finance.currencyNumericCode",
"faker.finance.currencySymbol",
"faker.finance.ethereumAddress",
"faker.finance.iban",
"faker.finance.litecoinAddress",
"faker.finance.pin",
"faker.finance.routingNumber",
"faker.finance.transactionDescription",
"faker.finance.transactionType",
"faker.git.branch",
"faker.git.commitDate",
"faker.git.commitEntry",
"faker.git.commitMessage",
"faker.git.commitSha",
"faker.hacker.abbreviation",
"faker.hacker.adjective",
"faker.hacker.ingverb",
"faker.hacker.noun",
"faker.hacker.phrase",
"faker.hacker.verb",
"faker.image.avatar",
"faker.image.avatarGitHub",
"faker.image.dataUri",
"faker.image.personPortrait",
"faker.image.url",
"faker.image.urlLoremFlickr",
"faker.image.urlPicsumPhotos",
"faker.internet.displayName",
"faker.internet.domainName",
"faker.internet.domainSuffix",
"faker.internet.domainWord",
"faker.internet.email",
"faker.internet.emoji",
"faker.internet.exampleEmail",
"faker.internet.httpMethod",
"faker.internet.httpStatusCode",
"faker.internet.ip",
"faker.internet.ipv4",
"faker.internet.ipv6",
"faker.internet.jwt",
"faker.internet.jwtAlgorithm",
"faker.internet.mac",
"faker.internet.password",
"faker.internet.port",
"faker.internet.protocol",
"faker.internet.url",
"faker.internet.userAgent",
"faker.internet.username",
"faker.location.buildingNumber",
"faker.location.cardinalDirection",
"faker.location.city",
"faker.location.continent",
"faker.location.country",
"faker.location.countryCode",
"faker.location.county",
"faker.location.direction",
"faker.location.language",
"faker.location.latitude",
"faker.location.longitude",
"faker.location.nearbyGPSCoordinate",
"faker.location.ordinalDirection",
"faker.location.secondaryAddress",
"faker.location.state",
"faker.location.street",
"faker.location.streetAddress",
"faker.location.timeZone",
"faker.location.zipCode",
"faker.lorem.lines",
"faker.lorem.paragraph",
"faker.lorem.paragraphs",
"faker.lorem.sentence",
"faker.lorem.sentences",
"faker.lorem.slug",
"faker.lorem.text",
"faker.lorem.word",
"faker.lorem.words",
"faker.music.album",
"faker.music.artist",
"faker.music.genre",
"faker.music.songName",
"faker.number.bigInt",
"faker.number.binary",
"faker.number.float",
"faker.number.hex",
"faker.number.int",
"faker.number.octal",
"faker.number.romanNumeral",
"faker.person.bio",
"faker.person.firstName",
"faker.person.fullName",
"faker.person.gender",
"faker.person.jobArea",
"faker.person.jobDescriptor",
"faker.person.jobTitle",
"faker.person.jobType",
"faker.person.lastName",
"faker.person.middleName",
"faker.person.prefix",
"faker.person.sex",
"faker.person.sexType",
"faker.person.suffix",
"faker.person.zodiacSign",
"faker.phone.imei",
"faker.phone.number",
"faker.science.chemicalElement",
"faker.science.unit",
"faker.string.alpha",
"faker.string.alphanumeric",
"faker.string.binary",
"faker.string.fromCharacters",
"faker.string.hexadecimal",
"faker.string.nanoid",
"faker.string.numeric",
"faker.string.octal",
"faker.string.sample",
"faker.string.symbol",
"faker.string.ulid",
"faker.string.uuid",
"faker.system.commonFileExt",
"faker.system.commonFileName",
"faker.system.commonFileType",
"faker.system.cron",
"faker.system.directoryPath",
"faker.system.fileExt",
"faker.system.fileName",
"faker.system.filePath",
"faker.system.fileType",
"faker.system.mimeType",
"faker.system.networkInterface",
"faker.system.semver",
"faker.vehicle.bicycle",
"faker.vehicle.color",
"faker.vehicle.fuel",
"faker.vehicle.manufacturer",
"faker.vehicle.model",
"faker.vehicle.type",
"faker.vehicle.vehicle",
"faker.vehicle.vin",
"faker.vehicle.vrm",
"faker.word.adjective",
"faker.word.adverb",
"faker.word.conjunction",
"faker.word.interjection",
"faker.word.noun",
"faker.word.preposition",
"faker.word.sample",
"faker.word.verb",
"faker.word.words",
]
`;

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
describe('template-function-faker', () => {
it('exports all expected template functions', async () => {
const { plugin } = await import('../src/index');
const names = plugin.templateFunctions?.map((fn) => fn.name).sort() ?? [];
// Snapshot the full list of exported function names so we catch any
// accidental additions, removals, or renames across faker upgrades.
expect(names).toMatchSnapshot();
});
});

View File

@@ -55,6 +55,7 @@ const requestArg: FormInput = {
type: 'http_request',
name: 'request',
label: 'Request',
defaultValue: '', // Make it not select the active one by default
};
export const plugin: PluginDefinition = {

View File

@@ -317,7 +317,7 @@ function TextArg({
autocompleteFunctions,
autocompleteVariables,
};
if (autocompleteVariables || autocompleteFunctions) {
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
return <Input {...props} />;
}
return <PlainInput {...props} />;
@@ -360,8 +360,9 @@ function EditorArg({
className={classNames(
'border border-border rounded-md overflow-hidden px-2 py-1',
'focus-within:border-border-focus',
'max-h-[10rem]', // So it doesn't take up too much space
!arg.rows && 'max-h-[10rem]', // So it doesn't take up too much space
)}
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
>
<Editor
id={id}

View File

@@ -10,12 +10,16 @@ import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useModelAncestors } from '../hooks/useModelAncestors';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { hideDialog } from '../lib/dialog';
import { CopyIconButton } from './CopyIconButton';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { Input } from './core/Input';
import { Link } from './core/Link';
import { VStack } from './core/Stacks';
import { HStack, VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
@@ -117,7 +121,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
<HttpAuthenticationEditor model={folder} />
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<VStack space={3} className="pb-3 h-full">
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full">
<Input
label="Folder Name"
defaultValue={folder.name}
@@ -132,7 +136,32 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
stateKey={`description.${folder.id}`}
onChange={(description) => patchModel(folder, { description })}
/>
</VStack>
<HStack alignItems="center" justifyContent="between" className="w-full">
<Button
onClick={async () => {
const didDelete = await deleteModelWithConfirm(folder);
if (didDelete) {
hideDialog('folder-settings');
}
}}
color="danger"
variant="border"
size="xs"
>
Delete Folder
</Button>
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{folder.id}
<CopyIconButton
className="opacity-70 !text-primary"
size="2xs"
iconSize="sm"
title="Copy folder ID"
text={folder.id}
/>
</InlineCode>
</HStack>
</div>
</TabContent>
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
<HeadersEditor

View File

@@ -239,7 +239,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
) : mimeType?.match(/csv|tab-separated/i) && viewMode === 'pretty' ? (
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer

View File

@@ -5,6 +5,7 @@ import { useMemo } from 'react';
import { Overlay } from '../Overlay';
import { Heading } from './Heading';
import { IconButton } from './IconButton';
import { DialogSize } from '@yaakapp-internal/plugins';
export interface DialogProps {
children: ReactNode;
@@ -14,7 +15,7 @@ export interface DialogProps {
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: 'sm' | 'md' | 'lg' | 'full' | 'dynamic';
size?: DialogSize;
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;

View File

@@ -5,11 +5,14 @@ import {
completionKeymap,
} from '@codemirror/autocomplete';
import { history, historyKeymap } from '@codemirror/commands';
import { go } from '@codemirror/lang-go';
import { java } from '@codemirror/lang-java';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { php } from '@codemirror/lang-php';
import { python } from '@codemirror/lang-python';
import { xml } from '@codemirror/lang-xml';
import type { LanguageSupport } from '@codemirror/language';
import {
bracketMatching,
codeFolding,
@@ -17,8 +20,19 @@ import {
foldKeymap,
HighlightStyle,
indentOnInput,
LanguageSupport,
StreamLanguage,
syntaxHighlighting,
} from '@codemirror/language';
import { c, csharp, kotlin, objectiveC } from '@codemirror/legacy-modes/mode/clike';
import { clojure } from '@codemirror/legacy-modes/mode/clojure';
import { http } from '@codemirror/legacy-modes/mode/http';
import { oCaml } from '@codemirror/legacy-modes/mode/mllike';
import { powerShell } from '@codemirror/legacy-modes/mode/powershell';
import { r } from '@codemirror/legacy-modes/mode/r';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { swift } from '@codemirror/legacy-modes/mode/swift';
import { linter, lintGutter, lintKeymap } from '@codemirror/lint';
import { search, searchKeymap } from '@codemirror/search';
@@ -83,6 +97,10 @@ const syntaxTheme = EditorView.theme({}, { dark: true });
const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
return () => new LanguageSupport(StreamLanguage.define(mode));
};
const syntaxExtensions: Record<
NonNullable<EditorProps['language']>,
null | (() => LanguageSupport)
@@ -98,6 +116,22 @@ const syntaxExtensions: Record<
text: text,
timeline: timeline,
markdown: markdown,
c: legacyLang(c),
clojure: legacyLang(clojure),
csharp: legacyLang(csharp),
go: go,
http: legacyLang(http),
java: java,
kotlin: legacyLang(kotlin),
objective_c: legacyLang(objectiveC),
ocaml: legacyLang(oCaml),
php: php,
powershell: legacyLang(powerShell),
python: python,
r: legacyLang(r),
ruby: legacyLang(ruby),
shell: legacyLang(shell),
swift: legacyLang(swift),
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript', 'graphql'];

View File

@@ -1,6 +1,6 @@
import type { FormInput, JsonPrimitive } from '@yaakapp-internal/plugins';
import type { FormEvent } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { generateId } from '../../lib/generateId';
import { DynamicForm } from '../DynamicForm';
import { Button } from './Button';
@@ -12,16 +12,21 @@ export interface PromptProps {
onResult: (value: Record<string, JsonPrimitive> | null) => void;
confirmText?: string;
cancelText?: string;
onValuesChange?: (values: Record<string, JsonPrimitive>) => void;
onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
}
export function Prompt({
onCancel,
inputs,
inputs: initialInputs,
onResult,
confirmText = 'Confirm',
cancelText = 'Cancel',
onValuesChange,
onInputsUpdated,
}: PromptProps) {
const [value, setValue] = useState<Record<string, JsonPrimitive>>({});
const [inputs, setInputs] = useState<FormInput[]>(initialInputs);
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -30,6 +35,16 @@ export function Prompt({
[onResult, value],
);
// Register callback for external input updates (from plugin dynamic resolution)
useEffect(() => {
onInputsUpdated?.(setInputs);
}, [onInputsUpdated]);
// Notify of value changes for dynamic resolution
useEffect(() => {
onValuesChange?.(value);
}, [value, onValuesChange]);
const id = `prompt.form.${useRef(generateId()).current}`;
return (

View File

@@ -7,6 +7,7 @@ import { forwardRef } from 'react';
import { openWorkspaceSettings } from '../../commands/openWorkspaceSettings';
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../../hooks/useActiveWorkspace';
import { useKeyValue } from '../../hooks/useKeyValue';
import { useRandomKey } from '../../hooks/useRandomKey';
import { sync } from '../../init/sync';
import { showConfirm, showConfirmDelete } from '../../lib/confirm';
import { showDialog } from '../../lib/dialog';
@@ -36,6 +37,7 @@ export function GitDropdown() {
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useAtomValue(activeWorkspaceAtom);
const [refreshKey, regenerateKey] = useRandomKey();
const [
{ status, log },
{
@@ -43,7 +45,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
deleteBranch,
deleteRemoteBranch,
renameBranch,
fetchAll,
mergeBranch,
push,
pull,
@@ -51,7 +52,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
resetChanges,
init,
},
] = useGit(syncDir, gitCallbacks(syncDir));
] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);
const localBranches = status.data?.localBranches ?? [];
const remoteBranches = status.data?.remoteBranches ?? [];
@@ -172,7 +173,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
{ type: 'separator' },
{
label: 'Push',
disabled: !hasRemotes || ahead === 0,
hidden: !hasRemotes,
leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true,
async onSelect() {
@@ -191,7 +192,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
{
label: 'Pull',
disabled: !hasRemotes || behind === 0,
hidden: !hasRemotes,
leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true,
async onSelect() {
@@ -210,7 +211,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
{
label: 'Commit...',
disabled: !hasChanges,
leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() {
showDialog({
@@ -502,15 +503,25 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
];
return (
<Dropdown fullWidth items={items} onOpen={fetchAll.mutate}>
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
<GitMenuButton>
<InlineCode className="flex items-center gap-1">
<Icon icon="git_branch" size="xs" className="opacity-50" />
{currentBranch}
</InlineCode>
<div className="flex items-center gap-1.5">
{ahead > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-primary"></span>{ahead}</span>}
{behind > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-info"></span>{behind}</span>}
{ahead > 0 && (
<span className="text-xs flex items-center gap-0.5">
<span className="text-primary"></span>
{ahead}
</span>
)}
{behind > 0 && (
<span className="text-xs flex items-center gap-0.5">
<span className="text-info"></span>
{behind}
</span>
)}
</div>
</GitMenuButton>
</Dropdown>

View File

@@ -1,6 +1,12 @@
import { emit } from '@tauri-apps/api/event';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { InternalEvent, ShowToastRequest } from '@yaakapp-internal/plugins';
import { debounce } from '@yaakapp-internal/lib';
import type {
FormInput,
InternalEvent,
JsonPrimitive,
ShowToastRequest,
} from '@yaakapp-internal/plugins';
import { updateAllPlugins } from '@yaakapp-internal/plugins';
import type {
PluginUpdateNotification,
@@ -32,6 +38,9 @@ export function initGlobalListeners() {
listenToTauriEvent('settings', () => openSettings.mutate(null));
// Track active dynamic form dialogs so follow-up input updates can reach them
const activeForms = new Map<string, (inputs: FormInput[]) => void>();
// Listen for plugin events
listenToTauriEvent<InternalEvent>('plugin_event', async ({ payload: event }) => {
if (event.payload.type === 'prompt_text_request') {
@@ -49,26 +58,47 @@ export function initGlobalListeners() {
};
await emit(event.id, result);
} else if (event.payload.type === 'prompt_form_request') {
if (event.replyId != null) {
// Follow-up update from plugin runtime — update the active dialog's inputs
const updateInputs = activeForms.get(event.replyId);
if (updateInputs) {
updateInputs(event.payload.inputs);
}
return;
}
// Initial request — show the dialog with bidirectional support
const emitFormResponse = (values: Record<string, JsonPrimitive> | null, done: boolean) => {
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: 'prompt_form_response',
values,
done,
},
};
emit(event.id, result);
};
const values = await showPromptForm({
id: event.payload.id,
title: event.payload.title,
description: event.payload.description,
size: event.payload.size,
inputs: event.payload.inputs,
confirmText: event.payload.confirmText,
cancelText: event.payload.cancelText,
onValuesChange: debounce((values) => emitFormResponse(values, false), 150),
onInputsUpdated: (cb) => activeForms.set(event.id, cb),
});
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: 'prompt_form_response',
values,
},
};
await emit(event.id, result);
// Clean up and send final response
activeForms.delete(event.id);
emitFormResponse(values, true);
}
});

View File

@@ -1,21 +1,32 @@
import type { FormInput, JsonPrimitive } from '@yaakapp-internal/plugins';
import type { DialogProps } from '../components/core/Dialog';
import type { PromptProps } from '../components/core/Prompt';
import { Prompt } from '../components/core/Prompt';
import { showDialog } from './dialog';
type FormArgs = Pick<DialogProps, 'title' | 'description'> &
type FormArgs = Pick<DialogProps, 'title' | 'description' | 'size'> &
Omit<PromptProps, 'onClose' | 'onCancel' | 'onResult'> & {
id: string;
onValuesChange?: (values: Record<string, JsonPrimitive>) => void;
onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
};
export async function showPromptForm({ id, title, description, ...props }: FormArgs) {
export async function showPromptForm({
id,
title,
description,
size,
onValuesChange,
onInputsUpdated,
...props
}: FormArgs) {
return new Promise((resolve: PromptProps['onResult']) => {
showDialog({
id,
title,
description,
hideX: true,
size: 'sm',
size: size ?? 'sm',
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
@@ -32,6 +43,8 @@ export async function showPromptForm({ id, title, description, ...props }: FormA
resolve(v);
hide();
},
onValuesChange,
onInputsUpdated,
...props,
}),
});