Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:34:10.860Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

main.rs

691 lines · 23.9 KB · rust
1use std::collections::BTreeSet;
2use std::collections::HashMap;
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::str::FromStr as _;
7use std::sync::Arc;
8
9use ::fs::{CopyOptions, Fs, RealFs, RemoveOptions, copy_recursive};
10use anyhow::{Context as _, Result, anyhow, bail};
11use clap::Parser;
12use cloud_api_types::ExtensionProvides;
13use extension::build_debug_adapter_schema_path;
14use extension::extension_builder::CompilationConcurrency;
15use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
16use extension::{ExtensionManifest, ExtensionSnippets};
17use language::LanguageConfig;
18use reqwest_client::ReqwestClient;
19use settings_content::SemanticTokenRules;
20use snippet_provider::file_to_snippets;
21use snippet_provider::format::VsSnippetsFile;
22use task::TaskTemplates;
23use tokio::process::Command;
24use tree_sitter::{Language, Query, WasmStore};
25
26#[derive(Parser, Debug)]
27#[command(name = "zed-extension")]
28struct Args {
29    /// The path to the extension directory
30    #[arg(long)]
31    source_dir: PathBuf,
32    /// The output directory to place the packaged extension.
33    #[arg(long)]
34    output_dir: PathBuf,
35    /// The path to a directory where build dependencies are downloaded
36    #[arg(long)]
37    scratch_dir: PathBuf,
38}
39
40#[tokio::main]
41async fn main() -> Result<()> {
42    env_logger::init();
43
44    let args = Args::parse();
45    let fs = Arc::new(RealFs::new(None, gpui_platform::background_executor()));
46    let engine = wasmtime::Engine::default();
47    let mut wasm_store = WasmStore::new(&engine)?;
48
49    let extension_path = args
50        .source_dir
51        .canonicalize()
52        .context("failed to canonicalize source_dir")?;
53
54    fs.create_dir(&args.scratch_dir)
55        .await
56        .context("failed to create scratch dir")?;
57
58    let scratch_dir = args
59        .scratch_dir
60        .canonicalize()
61        .context("failed to canonicalize scratch_dir")?;
62    let output_dir = if args.output_dir.is_relative() {
63        env::current_dir()?.join(&args.output_dir)
64    } else {
65        args.output_dir
66    };
67
68    log::info!("loading extension manifest");
69    let mut manifest = ExtensionManifest::load(fs.clone(), &extension_path).await?;
70
71    log::info!("compiling extension");
72
73    let user_agent = format!(
74        "Omega Extension CLI/{} ({}; {})",
75        env!("CARGO_PKG_VERSION"),
76        std::env::consts::OS,
77        std::env::consts::ARCH
78    );
79    let http_client = Arc::new(ReqwestClient::user_agent(&user_agent)?);
80
81    let builder = ExtensionBuilder::new(http_client, scratch_dir);
82    builder
83        .compile_extension(
84            &extension_path,
85            &mut manifest,
86            CompileExtensionOptions {
87                release: true,
88                max_concurrency: CompilationConcurrency::Unbounded,
89            },
90            fs.clone(),
91        )
92        .await
93        .context("failed to compile extension")?;
94
95    let extension_provides = manifest.provides();
96    validate_extension_features(&extension_provides)?;
97
98    let grammars = test_grammars(&manifest, &extension_path, &mut wasm_store)?;
99    test_languages(&manifest, &extension_path, &grammars)?;
100    test_themes(&manifest, &extension_path, fs.clone()).await?;
101    test_snippets(&manifest, &extension_path, fs.clone()).await?;
102    test_debug_adapter_schemas(&manifest, &extension_path, fs.clone()).await?;
103
104    let archive_dir = output_dir.join("archive");
105    fs.remove_dir(
106        &archive_dir,
107        RemoveOptions {
108            recursive: true,
109            ignore_if_not_exists: true,
110        },
111    )
112    .await
113    .ok();
114    copy_extension_resources(&manifest, &extension_path, &archive_dir, fs.clone())
115        .await
116        .context("failed to copy extension resources")?;
117
118    let tar_output = Command::new("tar")
119        .current_dir(&output_dir)
120        .args(["-czvf", "archive.tar.gz", "-C", "archive", "."])
121        .output()
122        .await
123        .context("failed to run tar")?;
124    if !tar_output.status.success() {
125        bail!(
126            "failed to create archive.tar.gz: {}",
127            String::from_utf8_lossy(&tar_output.stderr)
128        );
129    }
130
131    let manifest_json = serde_json::to_string(&cloud_api_types::ExtensionApiManifest {
132        name: manifest.name,
133        version: manifest.version,
134        description: manifest.description,
135        authors: manifest.authors,
136        schema_version: Some(manifest.schema_version.0),
137        repository: manifest
138            .repository
139            .context("missing repository in extension manifest")?,
140        wasm_api_version: manifest.lib.version.map(|version| version.to_string()),
141        provides: extension_provides,
142    })?;
143    fs.remove_dir(
144        &archive_dir,
145        RemoveOptions {
146            recursive: true,
147            ignore_if_not_exists: false,
148        },
149    )
150    .await?;
151    fs.write(&output_dir.join("manifest.json"), manifest_json.as_bytes())
152        .await?;
153
154    Ok(())
155}
156
157async fn copy_extension_resources(
158    manifest: &ExtensionManifest,
159    extension_path: &Path,
160    output_dir: &Path,
161    fs: Arc<dyn Fs>,
162) -> Result<()> {
163    fs.create_dir(output_dir)
164        .await
165        .context("failed to create output dir")?;
166
167    let manifest_toml = toml::to_string(&manifest).context("failed to serialize manifest")?;
168    fs.write(&output_dir.join("extension.toml"), manifest_toml.as_bytes())
169        .await
170        .context("failed to write extension.toml")?;
171
172    if manifest.lib.kind.is_some() {
173        fs.copy_file(
174            &extension_path.join("extension.wasm"),
175            &output_dir.join("extension.wasm"),
176            CopyOptions {
177                overwrite: true,
178                ignore_if_exists: false,
179            },
180        )
181        .await
182        .context("failed to copy extension.wasm")?;
183    }
184
185    if !manifest.grammars.is_empty() {
186        let source_grammars_dir = extension_path.join("grammars");
187        let output_grammars_dir = output_dir.join("grammars");
188        fs.create_dir(&output_grammars_dir).await?;
189        futures::future::try_join_all(manifest.grammars.keys().map(|grammar_name| {
190            let fs = fs.clone();
191            let source_grammars_dir = source_grammars_dir.as_path();
192            let output_grammars_dir = output_grammars_dir.as_path();
193            async move {
194                let mut grammar_filename = PathBuf::from(grammar_name.as_ref());
195                grammar_filename.set_extension("wasm");
196                fs.copy_file(
197                    &source_grammars_dir.join(&grammar_filename),
198                    &output_grammars_dir.join(&grammar_filename),
199                    CopyOptions {
200                        overwrite: true,
201                        ignore_if_exists: false,
202                    },
203                )
204                .await
205                .with_context(|| format!("failed to copy grammar '{}'", grammar_filename.display()))
206            }
207        }))
208        .await?;
209    }
210
211    if !manifest.themes.is_empty() {
212        let output_themes_dir = output_dir.join("themes");
213        fs.create_dir(&output_themes_dir).await?;
214        futures::future::try_join_all(manifest.themes.iter().map(|theme_path| {
215            let fs = fs.clone();
216            let output_themes_dir = output_themes_dir.as_path();
217            async move {
218                let theme_path = theme_path.as_std_path();
219                fs.copy_file(
220                    &extension_path.join(theme_path),
221                    &output_themes_dir.join(theme_path.file_name().context("invalid theme path")?),
222                    CopyOptions {
223                        overwrite: true,
224                        ignore_if_exists: false,
225                    },
226                )
227                .await
228                .with_context(|| format!("failed to copy theme '{}'", theme_path.display()))
229            }
230        }))
231        .await?;
232    }
233
234    if !manifest.icon_themes.is_empty() {
235        let output_icon_themes_dir = output_dir.join("icon_themes");
236        fs.create_dir(&output_icon_themes_dir).await?;
237        futures::future::try_join_all(manifest.icon_themes.iter().map(|icon_theme_path| {
238            let fs = fs.clone();
239            let output_icon_themes_dir = output_icon_themes_dir.as_path();
240            async move {
241                let icon_theme_path = icon_theme_path.as_std_path();
242                fs.copy_file(
243                    &extension_path.join(icon_theme_path),
244                    &output_icon_themes_dir.join(
245                        icon_theme_path
246                            .file_name()
247                            .context("invalid icon theme path")?,
248                    ),
249                    CopyOptions {
250                        overwrite: true,
251                        ignore_if_exists: false,
252                    },
253                )
254                .await
255                .with_context(|| {
256                    format!("failed to copy icon theme '{}'", icon_theme_path.display())
257                })
258            }
259        }))
260        .await?;
261
262        let output_icons_dir = output_dir.join("icons");
263        fs.create_dir(&output_icons_dir).await?;
264        copy_recursive(
265            fs.as_ref(),
266            &extension_path.join("icons"),
267            &output_icons_dir,
268            CopyOptions {
269                overwrite: true,
270                ignore_if_exists: false,
271            },
272        )
273        .await
274        .context("failed to copy icons")?;
275    }
276
277    if !manifest.languages.is_empty() {
278        let output_languages_dir = output_dir.join("languages");
279        fs.create_dir(&output_languages_dir).await?;
280        futures::future::try_join_all(manifest.languages.iter().map(|language_path| {
281            let fs = fs.clone();
282            let output_languages_dir = output_languages_dir.clone();
283            async move {
284                let language_path = language_path.as_std_path();
285                copy_recursive(
286                    fs.as_ref(),
287                    &extension_path.join(language_path),
288                    &output_languages_dir
289                        .join(language_path.file_name().context("invalid language path")?),
290                    CopyOptions {
291                        overwrite: true,
292                        ignore_if_exists: false,
293                    },
294                )
295                .await
296                .with_context(|| {
297                    format!("failed to copy language dir '{}'", language_path.display())
298                })
299            }
300        }))
301        .await?;
302    }
303
304    if !manifest.debug_adapters.is_empty() {
305        futures::future::try_join_all(manifest.debug_adapters.iter().map(
306            |(debug_adapter, entry)| {
307                let fs = fs.clone();
308                let debug_adapter = debug_adapter.clone();
309                async move {
310                    let schema_path =
311                        extension::build_debug_adapter_schema_path(&debug_adapter, &entry)?;
312                    let parent = schema_path.parent().with_context(|| {
313                        format!("invalid empty schema path for {debug_adapter}")
314                    })?;
315                    let schema_path = schema_path.as_std_path();
316                    fs.create_dir(&output_dir.join(parent)).await?;
317                    copy_recursive(
318                        fs.as_ref(),
319                        &extension_path.join(schema_path),
320                        &output_dir.join(schema_path),
321                        CopyOptions {
322                            overwrite: true,
323                            ignore_if_exists: false,
324                        },
325                    )
326                    .await
327                    .with_context(|| {
328                        format!(
329                            "failed to copy debug adapter schema '{}'",
330                            schema_path.display(),
331                        )
332                    })
333                }
334            },
335        ))
336        .await?;
337    }
338
339    if let Some(snippets) = manifest.snippets.as_ref() {
340        futures::future::try_join_all(snippets.paths().map(|snippets_path| {
341            let fs = fs.clone();
342            async move {
343                let parent = snippets_path.parent();
344                if let Some(parent) = parent.filter(|p| p.components().next().is_some()) {
345                    fs.create_dir(&output_dir.join(parent)).await?;
346                }
347                copy_recursive(
348                    fs.as_ref(),
349                    &extension_path.join(&snippets_path),
350                    &output_dir.join(&snippets_path),
351                    CopyOptions {
352                        overwrite: true,
353                        ignore_if_exists: false,
354                    },
355                )
356                .await
357                .with_context(|| {
358                    format!("failed to copy snippets from '{}'", snippets_path.display())
359                })
360            }
361        }))
362        .await?;
363    }
364
365    Ok(())
366}
367
368#[derive(Debug, PartialEq, Eq, thiserror::Error)]
369enum ExtensionFeatureError {
370    #[error("extension does not provide any features")]
371    NoFeatures,
372    #[error("extension must not provide other features along with themes")]
373    ThemesMixedWithOtherFeatures,
374    #[error("extension must not provide other features along with icon themes")]
375    IconThemesMixedWithOtherFeatures,
376    #[error(
377        "Slash commands have been deprecated and \
378        the slash command API will be removed in a future release. {}",
379        if *.sole_feature {
380            "Slash command extensions will no longer be accepted at this time."
381        } else {
382            "Please remove any slash-command related code from your extension."
383        }
384    )]
385    SlashCommandsDeprecated { sole_feature: bool },
386}
387
388fn validate_extension_features(
389    provides: &BTreeSet<ExtensionProvides>,
390) -> Result<(), ExtensionFeatureError> {
391    if provides.is_empty() {
392        return Err(ExtensionFeatureError::NoFeatures);
393    }
394
395    let provides_single_feature = provides.len() == 1;
396
397    if provides.contains(&ExtensionProvides::Themes) && !provides_single_feature {
398        return Err(ExtensionFeatureError::ThemesMixedWithOtherFeatures);
399    }
400
401    if provides.contains(&ExtensionProvides::IconThemes) && !provides_single_feature {
402        return Err(ExtensionFeatureError::IconThemesMixedWithOtherFeatures);
403    }
404
405    if provides.contains(&ExtensionProvides::SlashCommands) {
406        return Err(ExtensionFeatureError::SlashCommandsDeprecated {
407            sole_feature: provides_single_feature,
408        });
409    }
410
411    Ok(())
412}
413
414fn test_grammars(
415    manifest: &ExtensionManifest,
416    extension_path: &Path,
417    wasm_store: &mut WasmStore,
418) -> Result<HashMap<String, Language>> {
419    let mut grammars = HashMap::default();
420    let grammars_dir = extension_path.join("grammars");
421
422    for grammar_name in manifest.grammars.keys() {
423        let mut grammar_path = grammars_dir.join(grammar_name.as_ref());
424        grammar_path.set_extension("wasm");
425
426        let wasm = fs::read(&grammar_path)?;
427        let language = wasm_store.load_language(grammar_name, &wasm)?;
428        log::info!("loaded grammar {grammar_name}");
429        grammars.insert(grammar_name.to_string(), language);
430    }
431
432    Ok(grammars)
433}
434
435fn test_languages(
436    manifest: &ExtensionManifest,
437    extension_path: &Path,
438    grammars: &HashMap<String, Language>,
439) -> Result<()> {
440    for relative_language_dir in &manifest.languages {
441        let language_dir = extension_path.join(relative_language_dir);
442        let config_path = language_dir.join(LanguageConfig::FILE_NAME);
443        let config = LanguageConfig::load(&config_path)?;
444        let grammar = if let Some(name) = &config.grammar {
445            Some(
446                grammars
447                    .get(name.as_ref())
448                    .with_context(|| format!("grammar not found: '{name}'"))?,
449            )
450        } else {
451            None
452        };
453
454        let query_entries = fs::read_dir(&language_dir)?;
455        for entry in query_entries {
456            let entry = entry?;
457            let file_path = entry.path();
458
459            let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else {
460                continue;
461            };
462
463            match file_name {
464                LanguageConfig::FILE_NAME => {
465                    // Loaded above
466                }
467                SemanticTokenRules::FILE_NAME => {
468                    let _token_rules = SemanticTokenRules::load(&file_path)?;
469                }
470                TaskTemplates::FILE_NAME => {
471                    let task_file_content = std::fs::read(&file_path).with_context(|| {
472                        anyhow!(
473                            "Failed to read tasks file at {path}",
474                            path = file_path.display()
475                        )
476                    })?;
477                    let _task_templates =
478                        serde_json_lenient::from_slice::<TaskTemplates>(&task_file_content)
479                            .with_context(|| {
480                                anyhow!(
481                                    "Failed to parse tasks file at {path}",
482                                    path = file_path.display()
483                                )
484                            })?;
485                }
486                _ if file_name.ends_with(".scm") => {
487                    let grammar = grammar.with_context(|| {
488                        format! {
489                            "language {} provides query {} but no grammar",
490                            config.name,
491                            file_path.display()
492                        }
493                    })?;
494
495                    let query_source = fs::read_to_string(&file_path)?;
496                    let _query = Query::new(grammar, &query_source)?;
497                }
498                _ => {}
499            }
500        }
501
502        log::info!("loaded language {}", config.name);
503    }
504
505    Ok(())
506}
507
508async fn test_themes(
509    manifest: &ExtensionManifest,
510    extension_path: &Path,
511    fs: Arc<dyn Fs>,
512) -> Result<()> {
513    for relative_theme_path in &manifest.themes {
514        let theme_path = extension_path.join(relative_theme_path);
515        let theme_family =
516            theme_settings::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?;
517        log::info!("loaded theme family {}", theme_family.name);
518
519        for theme in &theme_family.themes {
520            if theme
521                .style
522                .colors
523                .deprecated_scrollbar_thumb_background
524                .is_some()
525            {
526                bail!(
527                    r#"Theme "{theme_name}" is using a deprecated style property: scrollbar_thumb.background. Use `scrollbar.thumb.background` instead."#,
528                    theme_name = theme.name
529                )
530            }
531        }
532    }
533
534    Ok(())
535}
536
537async fn test_snippets(
538    manifest: &ExtensionManifest,
539    extension_path: &Path,
540    fs: Arc<dyn Fs>,
541) -> Result<()> {
542    for relative_snippet_path in manifest
543        .snippets
544        .as_ref()
545        .map(ExtensionSnippets::paths)
546        .into_iter()
547        .flatten()
548    {
549        let snippet_path = extension_path.join(relative_snippet_path);
550        let snippets_content = fs.load_bytes(&snippet_path).await?;
551        let snippets_file = serde_json_lenient::from_slice::<VsSnippetsFile>(&snippets_content)
552            .with_context(|| anyhow!("Failed to parse snippet file at {snippet_path:?}"))?;
553        let snippet_errors = file_to_snippets(snippets_file, &snippet_path)
554            .flat_map(Result::err)
555            .collect::<Vec<_>>();
556        let error_count = snippet_errors.len();
557
558        anyhow::ensure!(
559            error_count == 0,
560            "Could not parse {error_count} snippet{suffix} in file {snippet_path:?}:\n\n{snippet_errors}",
561            suffix = if error_count == 1 { "" } else { "s" },
562            snippet_errors = snippet_errors
563                .iter()
564                .map(ToString::to_string)
565                .collect::<Vec<_>>()
566                .join("\n")
567        );
568    }
569
570    Ok(())
571}
572
573async fn test_debug_adapter_schemas(
574    manifest: &ExtensionManifest,
575    extension_path: &Path,
576    fs: Arc<dyn Fs>,
577) -> Result<()> {
578    futures::future::try_join_all(manifest.debug_adapters.iter().map(
579        |(debug_adapter_name, meta)| {
580            let fs = fs.clone();
581            async move {
582                let debug_adapter_schema_path =
583                    extension_path.join(build_debug_adapter_schema_path(debug_adapter_name, meta)?);
584
585                let debug_adapter_schema =
586                    fs.load(&debug_adapter_schema_path).await.with_context(|| {
587                        anyhow::anyhow!(
588                            "failed to read debug adapter schema for \
589                        `{debug_adapter_name}` from `{debug_adapter_schema_path:?}`"
590                        )
591                    })?;
592                _ = serde_json::Value::from_str(&debug_adapter_schema).with_context(|| {
593                    anyhow::anyhow!(
594                        "Debug adapter schema for `{debug_adapter_name}`\
595                        (path: `{debug_adapter_schema_path:?}`) is not a valid JSON"
596                    )
597                })?;
598
599                Ok(())
600            }
601        },
602    ))
603    .await
604    .map(|_| ())
605}
606
607#[cfg(test)]
608mod tests {
609    use cloud_api_types::ExtensionProvides;
610
611    use super::*;
612
613    #[test]
614    fn test_validate_empty_features() {
615        let provides = BTreeSet::new();
616        assert_eq!(
617            validate_extension_features(&provides),
618            Err(ExtensionFeatureError::NoFeatures),
619        );
620    }
621
622    #[test]
623    fn test_validate_single_language_feature() {
624        let provides = BTreeSet::from([ExtensionProvides::Languages]);
625        assert_eq!(validate_extension_features(&provides), Ok(()));
626    }
627
628    #[test]
629    fn test_validate_single_themes_feature() {
630        let provides = BTreeSet::from([ExtensionProvides::Themes]);
631        assert_eq!(validate_extension_features(&provides), Ok(()));
632    }
633
634    #[test]
635    fn test_validate_themes_with_other_features() {
636        let provides = BTreeSet::from([ExtensionProvides::Themes, ExtensionProvides::Languages]);
637        assert_eq!(
638            validate_extension_features(&provides),
639            Err(ExtensionFeatureError::ThemesMixedWithOtherFeatures),
640        );
641    }
642
643    #[test]
644    fn test_validate_single_icon_themes_feature() {
645        let provides = BTreeSet::from([ExtensionProvides::IconThemes]);
646        assert_eq!(validate_extension_features(&provides), Ok(()));
647    }
648
649    #[test]
650    fn test_validate_icon_themes_with_other_features() {
651        let provides = BTreeSet::from([ExtensionProvides::IconThemes, ExtensionProvides::Grammars]);
652        assert_eq!(
653            validate_extension_features(&provides),
654            Err(ExtensionFeatureError::IconThemesMixedWithOtherFeatures),
655        );
656    }
657
658    #[test]
659    fn test_validate_slash_commands_only() {
660        let provides = BTreeSet::from([ExtensionProvides::SlashCommands]);
661        assert_eq!(
662            validate_extension_features(&provides),
663            Err(ExtensionFeatureError::SlashCommandsDeprecated { sole_feature: true }),
664        );
665    }
666
667    #[test]
668    fn test_validate_slash_commands_with_other_features() {
669        let provides = BTreeSet::from([
670            ExtensionProvides::SlashCommands,
671            ExtensionProvides::Languages,
672        ]);
673        assert_eq!(
674            validate_extension_features(&provides),
675            Err(ExtensionFeatureError::SlashCommandsDeprecated {
676                sole_feature: false
677            }),
678        );
679    }
680
681    #[test]
682    fn test_validate_multiple_non_theme_features() {
683        let provides = BTreeSet::from([
684            ExtensionProvides::Languages,
685            ExtensionProvides::Grammars,
686            ExtensionProvides::LanguageServers,
687        ]);
688        assert_eq!(validate_extension_features(&provides), Ok(()));
689    }
690}
691
Served at tenant.openagents/omega Member data and write actions are omitted.