Skip to repository content691 lines · 23.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:42:56.968Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
main.rs
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