Skip to main content

objectiveai_cli/filesystem/plugins/
client.rs

1//! Plugin discovery on the local filesystem.
2//!
3//! Installed plugins live at
4//! `<base_dir>/plugins/<owner>/<name>/<version>/`, with the cli-side
5//! payload at `…/cli/` (the exec working directory, extracted from
6//! the manifest's `cli_zip` when one is declared), the optional
7//! viewer bundle at `…/viewer/`, and the manifest as
8//! `…/objectiveai.json` inside the version folder. The cli's
9//! `plugins run` dispatch uses [`Client::resolve_plugin`] to turn an
10//! `(owner, name, version)` coordinate into the platform's exec
11//! vector plus that `cli/` working directory — the same model tools
12//! use, with the extra `cli` folder.
13
14use std::path::{Path, PathBuf};
15
16use super::super::Client;
17use super::{Manifest, ManifestWithNameAndSource};
18
19/// Parse an on-disk `objectiveai.json` (a bare [`Manifest`]) into a
20/// [`ManifestWithNameAndSource`], deriving `name` from the `<name>`
21/// path segment (`.../<owner>/<name>/<version>/objectiveai.json`) and
22/// `source` from the file path. `None` on missing / unreadable /
23/// malformed / invalid files.
24async fn parse_manifest_file(path: &Path) -> Option<ManifestWithNameAndSource> {
25    let bytes = tokio::fs::read(path).await.ok()?;
26    let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
27    manifest.validate().ok()?;
28    // path = .../<owner>/<name>/<version>/objectiveai.json
29    // parent = <version>, parent.parent = <name>.
30    let name = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
31    let source = path.to_string_lossy().into_owned();
32    Some(ManifestWithNameAndSource {
33        name,
34        manifest,
35        source,
36    })
37}
38
39/// Walk `<root>/<owner>/<name>/<version>/objectiveai.json` and collect
40/// every existing manifest file path. Any non-directory / unreadable
41/// level is skipped.
42async fn collect_manifest_paths(root: PathBuf) -> Vec<PathBuf> {
43    let mut out: Vec<PathBuf> = Vec::new();
44    let Ok(mut owners) = tokio::fs::read_dir(&root).await else {
45        return out;
46    };
47    while let Ok(Some(owner_e)) = owners.next_entry().await {
48        let Ok(mut names) = tokio::fs::read_dir(owner_e.path()).await else {
49            continue;
50        };
51        while let Ok(Some(name_e)) = names.next_entry().await {
52            let Ok(mut versions) = tokio::fs::read_dir(name_e.path()).await else {
53                continue;
54            };
55            while let Ok(Some(ver_e)) = versions.next_entry().await {
56                let manifest = ver_e.path().join("objectiveai.json");
57                if tokio::fs::metadata(&manifest)
58                    .await
59                    .map(|m| m.is_file())
60                    .unwrap_or(false)
61                {
62                    out.push(manifest);
63                }
64            }
65        }
66    }
67    out
68}
69
70impl Client {
71    /// The plugins directory: `<bin_dir>/plugins` — installed
72    /// plugins are machine-wide, shared by every state.
73    pub fn plugins_dir(&self) -> PathBuf {
74        self.bin_dir().join("plugins")
75    }
76
77    /// The directory that holds a plugin's installed artifacts:
78    /// `<plugins_dir>/<owner>/<name>/<version>/`. Contains the
79    /// manifest `objectiveai.json`, the `cli/` exec working
80    /// directory, and an optional `viewer/` bundle.
81    pub fn plugin_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
82        self.plugins_dir().join(owner).join(name).join(version)
83    }
84
85    /// A plugin's cli working directory: `<plugin_dir>/cli/`. The
86    /// manifest's exec runs with this as CWD; `cli_zip` extracts
87    /// into it at install time.
88    pub fn plugin_cli_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
89        self.plugin_dir(owner, name, version).join("cli")
90    }
91
92    /// Resolve a plugin coordinate to its `(exec_vector, cli_dir)`
93    /// for the current platform — the same contract
94    /// [`Client::resolve_tool`](crate::filesystem::Client::resolve_tool)
95    /// has, with the plugin's `cli/` folder as the working directory.
96    /// `exec_vector` may be empty when the manifest declares no
97    /// command for this platform (viewer-only plugins; the caller
98    /// treats that as an error). `None` when the manifest is
99    /// missing/malformed/invalid.
100    pub async fn resolve_plugin(
101        &self,
102        owner: &str,
103        name: &str,
104        version: &str,
105    ) -> Option<(Vec<String>, PathBuf)> {
106        let bundle = self.get_plugin(owner, name, version).await?;
107        let cli_dir = self.plugin_cli_dir(owner, name, version);
108        Some((
109            crate::filesystem::tools::platform_exec(&bundle.manifest.exec),
110            cli_dir,
111        ))
112    }
113
114    /// Look up a single plugin manifest by coordinate. Reads
115    /// `<base_dir>/plugins/<owner>/<name>/<version>/objectiveai.json`.
116    /// Returns `None` if the file is missing, unreadable, malformed, or
117    /// invalid.
118    pub async fn get_plugin(
119        &self,
120        owner: &str,
121        name: &str,
122        version: &str,
123    ) -> Option<ManifestWithNameAndSource> {
124        let path = self
125            .plugin_dir(owner, name, version)
126            .join("objectiveai.json");
127        parse_manifest_file(&path).await
128    }
129
130    /// Enumerate plugin manifests by walking the
131    /// `plugins/<owner>/<name>/<version>/objectiveai.json` tree. Every
132    /// failure mode — missing dir, unreadable file, malformed JSON,
133    /// missing required field — is silently skipped; the return type is
134    /// plain `Vec` rather than `Result` to reflect that.
135    ///
136    /// Results are sorted by manifest mtime descending (most recently
137    /// modified first), then `skip(offset).take(limit)` is applied —
138    /// matching the convention of the logs list endpoints. Pass
139    /// `(0, usize::MAX)` for an unbounded list.
140    ///
141    /// The directory walk is sequential but per-file read+parse runs
142    /// concurrently via [`futures::future::join_all`].
143    pub async fn list_plugins(
144        &self,
145        offset: usize,
146        limit: usize,
147    ) -> Vec<ManifestWithNameAndSource> {
148        let paths = collect_manifest_paths(self.plugins_dir()).await;
149        let futures = paths.into_iter().map(|p| async move {
150            let bundle = parse_manifest_file(&p).await?;
151            let modified = tokio::fs::metadata(&p)
152                .await
153                .ok()?
154                .modified()
155                .ok()?
156                .duration_since(std::time::SystemTime::UNIX_EPOCH)
157                .ok()?
158                .as_secs();
159            Some((modified, bundle))
160        });
161        let mut entries: Vec<(u64, ManifestWithNameAndSource)> =
162            futures::future::join_all(futures)
163                .await
164                .into_iter()
165                .flatten()
166                .collect();
167        entries.sort_by(|a, b| b.0.cmp(&a.0));
168        let iter = entries.into_iter().map(|(_, m)| m);
169        if offset > 0 || limit < usize::MAX {
170            iter.skip(offset).take(limit).collect()
171        } else {
172            iter.collect()
173        }
174    }
175}
176
177impl Client {
178    /// Install a plugin from a GitHub repository.
179    ///
180    /// 1. Fetches `objectiveai.json` from `raw.githubusercontent.com`
181    ///    at the supplied `commit_sha` (or the default branch via
182    ///    `HEAD` when none).
183    /// 2. Parses it as a [`Manifest`].
184    /// 3. Downloads the declared release assets from
185    ///    `https://github.com/<owner>/<repository>/releases/download/v<version>/<asset>`:
186    ///    `cli_zip` (when declared) extracts into
187    ///    `<plugin dir>/cli/`, `viewer_zip` into `…/viewer/`.
188    ///    Neither is required — a manifest whose exec invokes
189    ///    PATH-resolved programs installs with just the manifest.
190    ///
191    /// `headers` is an optional `IndexMap<String, String>` that gets
192    /// attached to every HTTP request (e.g. `Authorization` for
193    /// private repos / higher rate limits). The cli always passes
194    /// `None`.
195    ///
196    /// Failures are returned as [`super::InstallError`] wrapped by
197    /// [`super::super::Error::Install`]. The `bool` is retained for
198    /// wire compatibility and is always `true` on success — the
199    /// platform gate that used to yield `Ok(false)` died with the
200    /// per-platform binaries map (per-OS support is now expressed by
201    /// the exec vectors themselves).
202    pub async fn install_plugin(
203        &self,
204        owner: &str,
205        repository: &str,
206        commit_sha: Option<&str>,
207        headers: Option<&indexmap::IndexMap<String, String>>,
208        upgrade: bool,
209    ) -> Result<bool, super::super::Error> {
210        validate_install_inputs(owner, repository, commit_sha)?;
211        let manifest = self
212            .fetch_plugin_manifest(owner, repository, commit_sha, headers)
213            .await?;
214        let source = raw_manifest_url(owner, repository, commit_sha);
215        self.install_plugin_from_manifest(
216            owner, repository, &manifest, &source, headers, upgrade,
217        )
218        .await
219    }
220
221    /// Step 1 of `install_plugin`: fetch `<owner>/<repo>/<ref>/objectiveai.json`
222    /// from `raw.githubusercontent.com` and parse it as a [`Manifest`].
223    /// Exposed publicly so callers can inspect the manifest before
224    /// committing to an install (e.g. for whitelist checks).
225    pub async fn fetch_plugin_manifest(
226        &self,
227        owner: &str,
228        repository: &str,
229        commit_sha: Option<&str>,
230        headers: Option<&indexmap::IndexMap<String, String>>,
231    ) -> Result<Manifest, super::super::Error> {
232        self.fetch_plugin_manifest_impl(
233            "https://raw.githubusercontent.com",
234            owner,
235            repository,
236            commit_sha,
237            headers,
238        )
239        .await
240    }
241
242    /// Step 2 of `install_plugin`: given an already-parsed manifest,
243    /// download its declared release assets (`cli_zip` → `cli/`,
244    /// `viewer_zip` → `viewer/`) and persist the manifest.
245    pub async fn install_plugin_from_manifest(
246        &self,
247        owner: &str,
248        repository: &str,
249        manifest: &Manifest,
250        source: &str,
251        headers: Option<&indexmap::IndexMap<String, String>>,
252        upgrade: bool,
253    ) -> Result<bool, super::super::Error> {
254        // `install_plugin_from_manifest` is a public entry — callers
255        // may hand us a manifest with no fetch step ever happening, so
256        // re-validate inputs here. `install_plugin` already validated
257        // before fetching; the second call is cheap and idempotent.
258        validate_install_inputs(owner, repository, None)?;
259        self.install_from_manifest_impl(
260            "https://github.com",
261            owner,
262            repository,
263            manifest,
264            source,
265            headers,
266            upgrade,
267        )
268        .await
269    }
270
271    /// Test-only entry point that exposes the raw / releases URL
272    /// bases so in-process mock servers can intercept the requests.
273    /// Threads both URLs through the same fetch + install_from path
274    /// used by production.
275    #[cfg(test)]
276    pub(super) async fn install_plugin_at(
277        &self,
278        raw_base: &str,
279        releases_base: &str,
280        owner: &str,
281        repository: &str,
282        commit_sha: Option<&str>,
283        headers: Option<&indexmap::IndexMap<String, String>>,
284        upgrade: bool,
285    ) -> Result<bool, super::super::Error> {
286        validate_install_inputs(owner, repository, commit_sha)?;
287        let manifest = self
288            .fetch_plugin_manifest_impl(
289                raw_base, owner, repository, commit_sha, headers,
290            )
291            .await?;
292        let reference = commit_sha.unwrap_or("HEAD");
293        let source = format!(
294            "{raw_base}/{owner}/{repository}/{reference}/objectiveai.json"
295        );
296        self.install_from_manifest_impl(
297            releases_base,
298            owner,
299            repository,
300            &manifest,
301            &source,
302            headers,
303            upgrade,
304        )
305        .await
306    }
307
308    /// Test-only fetch-only entry point, mirrors `install_plugin_at`.
309    #[cfg(test)]
310    pub(super) async fn fetch_plugin_manifest_at(
311        &self,
312        raw_base: &str,
313        owner: &str,
314        repository: &str,
315        commit_sha: Option<&str>,
316        headers: Option<&indexmap::IndexMap<String, String>>,
317    ) -> Result<Manifest, super::super::Error> {
318        self.fetch_plugin_manifest_impl(
319            raw_base, owner, repository, commit_sha, headers,
320        )
321        .await
322    }
323
324    async fn fetch_plugin_manifest_impl(
325        &self,
326        raw_base: &str,
327        owner: &str,
328        repository: &str,
329        commit_sha: Option<&str>,
330        headers: Option<&indexmap::IndexMap<String, String>>,
331    ) -> Result<Manifest, super::super::Error> {
332        let http = reqwest::Client::new();
333        let header_map = build_headers(headers)?;
334        let reference = commit_sha.unwrap_or("HEAD");
335        let manifest_url = format!(
336            "{raw_base}/{owner}/{repository}/{reference}/objectiveai.json"
337        );
338        let resp = http
339            .get(&manifest_url)
340            .headers(header_map)
341            .send()
342            .await
343            .map_err(super::InstallError::ManifestRequest)?;
344        let status = resp.status();
345        let bytes = resp
346            .bytes()
347            .await
348            .map_err(super::InstallError::ManifestResponse)?;
349        if !status.is_success() {
350            return Err(super::InstallError::ManifestBadStatus {
351                code: status,
352                url: manifest_url,
353                body: String::from_utf8_lossy(&bytes).into_owned(),
354            }
355            .into());
356        }
357        let mut de = serde_json::Deserializer::from_slice(&bytes);
358        let manifest: Manifest = serde_path_to_error::deserialize(&mut de)
359            .map_err(super::InstallError::ManifestParse)?;
360        manifest
361            .validate()
362            .map_err(super::InstallError::ManifestInvalid)?;
363        Ok(manifest)
364    }
365
366    async fn install_from_manifest_impl(
367        &self,
368        releases_base: &str,
369        owner: &str,
370        repository: &str,
371        manifest: &Manifest,
372        _source: &str,
373        headers: Option<&indexmap::IndexMap<String, String>>,
374        upgrade: bool,
375    ) -> Result<bool, super::super::Error> {
376        // 0. Tool-name budget check. Build the same string
377        //    `Manifest::tool_name` materializes (owner-name-version
378        //    with `.` -> `-`) and reject if longer than the 100-char
379        //    budget we leave under Anthropic's 128-char hard cap.
380        let tool_name = manifest.tool_name(repository);
381        if tool_name.len() > 100 {
382            return Err(super::InstallError::ToolNameTooLong {
383                len: tool_name.len(),
384                tool_name,
385            }
386            .into());
387        }
388
389        let version = manifest.version.clone();
390        let plugin_dir = self.plugin_dir(owner, repository, &version);
391        let cli_dir = self.plugin_cli_dir(owner, repository, &version);
392        let viewer_dir = plugin_dir.join("viewer");
393        let manifest_path = plugin_dir.join("objectiveai.json");
394
395        // 1. Existing-install check: the manifest sibling file is the
396        //    source of truth for "this plugin is installed."
397        let manifest_exists = tokio::fs::metadata(&manifest_path).await.is_ok();
398        if manifest_exists && !upgrade {
399            return Err(super::InstallError::AlreadyInstalled {
400                repository: repository.to_string(),
401            }
402            .into());
403        }
404
405        // 2. Network phase: fetch everything into memory before any
406        //    disk write. A network failure here leaves the disk
407        //    untouched — an existing install (upgrade path) keeps
408        //    working until the write phase actually begins.
409        let http = reqwest::Client::new();
410        let cli_zip_bytes: Option<Vec<u8>> = if let Some(cli_zip_name) =
411            &manifest.cli_zip
412        {
413            let cli_url = format!(
414                "{releases_base}/{owner}/{repository}/releases/download/v{version}/{cli_zip_name}",
415                version = manifest.version,
416            );
417            let resp = http
418                .get(&cli_url)
419                .headers(build_headers(headers)?)
420                .send()
421                .await
422                .map_err(super::InstallError::CliZipRequest)?;
423            let status = resp.status();
424            if !status.is_success() {
425                return Err(super::InstallError::CliZipBadStatus {
426                    code: status,
427                    url: cli_url,
428                }
429                .into());
430            }
431            Some(
432                resp.bytes()
433                    .await
434                    .map_err(super::InstallError::CliZipResponse)?
435                    .to_vec(),
436            )
437        } else {
438            None
439        };
440
441        let zip_bytes: Option<Vec<u8>> = if let Some(viewer_zip_name) =
442            &manifest.viewer_zip
443        {
444            let viewer_url = format!(
445                "{releases_base}/{owner}/{repository}/releases/download/v{version}/{viewer_zip_name}",
446                version = manifest.version,
447            );
448            let resp = http
449                .get(&viewer_url)
450                .headers(build_headers(headers)?)
451                .send()
452                .await
453                .map_err(super::InstallError::ViewerZipRequest)?;
454            let status = resp.status();
455            if !status.is_success() {
456                return Err(super::InstallError::ViewerZipBadStatus {
457                    code: status,
458                    url: viewer_url,
459                }
460                .into());
461            }
462            Some(
463                resp.bytes()
464                    .await
465                    .map_err(super::InstallError::ViewerZipResponse)?
466                    .to_vec(),
467            )
468        } else {
469            None
470        };
471
472        let manifest_bytes: Vec<u8> = {
473            // Override the author-claimed `owner` with the GitHub
474            // `<owner>` we were actually installed from — forks land
475            // on disk with the fork's owner, not the upstream's. The
476            // on-disk `objectiveai.json` is the bare manifest; `name`
477            // / `version` / `owner` are encoded in the directory path.
478            let mut manifest = manifest.clone();
479            manifest.owner = owner.to_string();
480            serde_json::to_vec_pretty(&manifest)
481                .map_err(super::InstallError::ManifestSerialize)?
482        };
483
484        // 3. Pre-write clean. The manifest is the installed-ness
485        //    commit gate, so it is removed FIRST — from here until
486        //    step 5 lands, the plugin reads as "not installed", and a
487        //    process killed anywhere in between leaves a retryable
488        //    not-installed state rather than an installed-but-broken
489        //    one. The zip target dirs are cleared so an extract can
490        //    never mix two releases' trees (or commit a partial one
491        //    from a previous kill). Best-effort: a truly stuck
492        //    artifact surfaces as a write-phase error below. Extra
493        //    entries under <plugin_dir>/ are untouched.
494        let _ = tokio::fs::remove_file(&manifest_path).await;
495        let _ = tokio::fs::remove_dir_all(&cli_dir).await;
496        let _ = tokio::fs::remove_dir_all(&viewer_dir).await;
497        tokio::fs::create_dir_all(&plugin_dir).await.map_err(|e| {
498            super::InstallError::PluginDirCreate(plugin_dir.clone(), e)
499        })?;
500
501        // 4. Zip extraction — both bundles concurrently (disjoint
502        //    trees, neither is the commit gate).
503        tokio::try_join!(
504            write_zip_branch(cli_dir, cli_zip_bytes),
505            write_zip_branch(viewer_dir, zip_bytes),
506        )?;
507
508        // 5. Manifest LAST, atomically — the commit gate. Only a
509        //    fully-extracted install ever becomes visible.
510        write_manifest_branch(manifest_path, manifest_bytes).await?;
511
512        Ok(true)
513    }
514}
515
516/// Extract a downloaded release zip into `dir` (used for both the
517/// `cli/` and `viewer/` bundles). `None` bytes = the manifest didn't
518/// declare this asset — no-op.
519async fn write_zip_branch(
520    dir: PathBuf,
521    zip_bytes: Option<Vec<u8>>,
522) -> Result<(), super::InstallError> {
523    let Some(bytes) = zip_bytes else {
524        return Ok(());
525    };
526    tokio::fs::create_dir_all(&dir).await.map_err(|e| {
527        super::InstallError::ZipExtract(dir.clone(), e.to_string())
528    })?;
529    let dir_for_blocking = dir.clone();
530    tokio::task::spawn_blocking(move || {
531        let cursor = std::io::Cursor::new(bytes);
532        let mut archive = zip::ZipArchive::new(cursor)
533            .map_err(|e| format!("zip archive open: {e}"))?;
534        archive
535            .extract(&dir_for_blocking)
536            .map_err(|e| format!("extract: {e}"))
537    })
538    .await
539    .map_err(|e| super::InstallError::ZipExtract(dir.clone(), format!("join: {e}")))?
540    .map_err(|e| super::InstallError::ZipExtract(dir.clone(), e))?;
541    Ok(())
542}
543
544async fn write_manifest_branch(
545    manifest_path: PathBuf,
546    bytes: Vec<u8>,
547) -> Result<(), super::InstallError> {
548    // Atomic replace: the manifest is the installed-ness commit
549    // gate — it must appear fully-written or not at all.
550    crate::filesystem::util::write_atomic(&manifest_path, &bytes)
551        .await
552        .map_err(|e| {
553            super::InstallError::ManifestPersist(manifest_path.clone(), e)
554        })
555}
556
557/// Reject reserved plugin repository names before any install
558/// side-effect. `objectiveai` (case-insensitive) is reserved because
559/// the viewer uses it as the Tauri channel name for built-in events;
560/// a plugin with that repository name would shadow them.
561fn check_repository_name(repository: &str) -> Result<(), super::InstallError> {
562    if repository.eq_ignore_ascii_case("objectiveai") {
563        return Err(super::InstallError::ReservedRepositoryName {
564            repository: repository.to_string(),
565        });
566    }
567    Ok(())
568}
569
570/// Identifier shape check shared by `owner`, `repository`, and
571/// `commit`: Anthropic's tool-name regex (`^[a-zA-Z0-9_-]{1,128}$`)
572/// plus `.` (so semver-shaped versions and dotted commit refs flow
573/// through cleanly; the `.` -> `-` substitution happens when the tool
574/// name is materialized via [`super::Manifest::tool_name`]).
575fn validate_identifier(
576    kind: &'static str,
577    value: &str,
578) -> Result<(), super::InstallError> {
579    let valid_len = !value.is_empty() && value.len() <= 128;
580    let valid_chars = value
581        .chars()
582        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
583    if !valid_len || !valid_chars {
584        return Err(super::InstallError::InvalidIdentifier {
585            kind,
586            value: value.to_string(),
587        });
588    }
589    Ok(())
590}
591
592/// Combined shape check for the three caller-supplied identifiers
593/// every install entry point takes. Used by `install_plugin`,
594/// `install_plugin_from_manifest`, and the `#[cfg(test)]`
595/// `install_plugin_at`. Calls [`check_repository_name`] first so a
596/// reserved-name failure takes precedence over a generic regex
597/// failure for the same input.
598fn validate_install_inputs(
599    owner: &str,
600    repository: &str,
601    commit_sha: Option<&str>,
602) -> Result<(), super::InstallError> {
603    check_repository_name(repository)?;
604    validate_identifier("owner", owner)?;
605    validate_identifier("repository", repository)?;
606    if let Some(sha) = commit_sha {
607        validate_identifier("commit", sha)?;
608    }
609    Ok(())
610}
611
612/// Convention: the raw-GitHub URL we'd fetch `objectiveai.json` from
613/// for a given (owner, repository, optional commit sha). Defaults to
614/// `HEAD` when no commit is supplied. Lifted out so the cli and the
615/// SDK's own `install_plugin` wrapper share one source of truth.
616pub fn raw_manifest_url(
617    owner: &str,
618    repository: &str,
619    commit_sha: Option<&str>,
620) -> String {
621    let reference = commit_sha.unwrap_or("HEAD");
622    format!(
623        "https://raw.githubusercontent.com/{owner}/{repository}/{reference}/objectiveai.json"
624    )
625}
626
627pub(super) fn build_headers(
628    headers: Option<&indexmap::IndexMap<String, String>>,
629) -> Result<reqwest::header::HeaderMap, super::InstallError> {
630    let mut out = reqwest::header::HeaderMap::new();
631    let Some(h) = headers else {
632        return Ok(out);
633    };
634    for (k, v) in h {
635        let name = reqwest::header::HeaderName::from_bytes(k.as_bytes())
636            .map_err(|e| super::InstallError::InvalidHeaderName {
637                name: k.clone(),
638                reason: e.to_string(),
639            })?;
640        let value = reqwest::header::HeaderValue::from_str(v).map_err(|e| {
641            super::InstallError::InvalidHeaderValue {
642                name: k.clone(),
643                reason: e.to_string(),
644            }
645        })?;
646        out.insert(name, value);
647    }
648    Ok(out)
649}